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
23 changes: 23 additions & 0 deletions faststack_core/health/endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from fastapi import APIRouter


def create_health_router(app_version: str = "0.1.0") -> APIRouter:
"""Create a health check router.

GET /health — simple liveness check
GET /health/detailed — includes version (DB check requires session dependency override)
"""
router = APIRouter(tags=["health"])

@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

@router.get("/health/detailed")
async def health_detailed() -> dict[str, str]:
return {
"status": "ok",
"version": app_version,
}

return router
21 changes: 21 additions & 0 deletions faststack_core/logging/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from dataclasses import dataclass, field


@dataclass
class LogConfig:
"""Logging configuration."""

level: str = "INFO"
format: str = "json" # "json" or "text"
app_name: str = "faststack"
# Fields that should always be masked in log output
sensitive_patterns: list[str] = field(
default_factory=lambda: [
"password",
"secret",
"token",
"api_key",
"authorization",
"credit_card",
]
)
44 changes: 44 additions & 0 deletions faststack_core/logging/masking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import Any

DEFAULT_SENSITIVE_PATTERNS = [
"password",
"secret",
"token",
"api_key",
"authorization",
"credit_card",
]
MASK_VALUE = "***MASKED***"


def mask_sensitive_data(
data: Any,
sensitive_patterns: list[str] | None = None,
max_depth: int = 5,
) -> Any:
"""Recursively mask values for keys matching sensitive patterns.

- Works on dicts, lists, and nested combinations
- Depth-limited to prevent performance issues on large payloads
- Does NOT mutate the input — returns a masked copy
- Key matching is case-insensitive substring match
"""
if max_depth <= 0:
return data

patterns = sensitive_patterns if sensitive_patterns is not None else DEFAULT_SENSITIVE_PATTERNS

if isinstance(data, dict):
masked = {}
for key, value in data.items():
key_lower = key.lower() if isinstance(key, str) else str(key).lower()
if any(pattern.lower() in key_lower for pattern in patterns):
masked[key] = MASK_VALUE
else:
masked[key] = mask_sensitive_data(value, patterns, max_depth - 1)
return masked

if isinstance(data, list):
return [mask_sensitive_data(item, patterns, max_depth - 1) for item in data]

return data
33 changes: 33 additions & 0 deletions faststack_core/logging/structured_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import logging
import sys
from contextvars import ContextVar

# This will be set by the correlation ID middleware
correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="")


def get_correlation_id() -> str:
return correlation_id_var.get()


class StructuredLogger:
"""Production-grade logger with dual output.

- Console: colored text for development (human-readable)
- JSON: structured for log aggregation (machine-readable)
"""

def setup(self, app_name: str = "faststack", log_level: str = "INFO") -> logging.Logger:
"""Configure and return a logger with the appropriate handlers."""
logger = logging.getLogger(app_name)
logger.setLevel(getattr(logging, log_level.upper()))
logger.handlers.clear()

# Console handler — simple text format
console = logging.StreamHandler(sys.stderr)
console.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
)
logger.addHandler(console)

return logger
27 changes: 27 additions & 0 deletions faststack_core/middleware/correlation_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import uuid
from collections.abc import Callable

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from faststack_core.logging.structured_logger import correlation_id_var


class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""Assigns a unique correlation ID to every request.

- Reads X-Correlation-ID from request header if present, otherwise generates UUID
- Sets the correlation_id_var contextvar so all logs include it
- Returns the correlation ID in the X-Correlation-ID response header
"""

async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override]
correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
token = correlation_id_var.set(correlation_id)
try:
response: Response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response
finally:
correlation_id_var.reset(token)
31 changes: 31 additions & 0 deletions faststack_core/middleware/request_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import logging
import time
from collections.abc import Callable

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from faststack_core.logging.structured_logger import get_correlation_id

logger = logging.getLogger("faststack.request")


class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Logs HTTP request completion with method, path, status, and duration."""

async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override]
start = time.perf_counter()
response: Response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_completed",
extra={
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(duration_ms, 2),
"correlation_id": get_correlation_id(),
},
)
return response
24 changes: 24 additions & 0 deletions faststack_core/middleware/security_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from collections.abc import Callable

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cache-Control": "no-store",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Adds standard security headers to every response."""

async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[override]
response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items():
response.headers.setdefault(header, value)
return response
39 changes: 39 additions & 0 deletions faststack_core/settings/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from dataclasses import dataclass, field


@dataclass
class FastStackConfig:
"""Configuration for setup_app().

Each boolean controls whether the corresponding middleware/feature
is registered. All enabled by default.
"""

# Middleware toggles
correlation_id: bool = True
request_logging: bool = True
security_headers: bool = True

# CORS (None = disabled, provide origins to enable)
cors_origins: list[str] | None = None

# Logging
log_level: str = "INFO"
log_format: str = "json" # "json" or "text"
sensitive_fields: list[str] = field(
default_factory=lambda: [
"password",
"secret",
"token",
"api_key",
"authorization",
]
)

# Health checks
health_check: bool = True
health_check_path: str = "/health"
app_version: str = "0.1.0"

# Exception handlers
exception_handlers: bool = True
58 changes: 58 additions & 0 deletions faststack_core/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from faststack_core.health.endpoints import create_health_router
from faststack_core.logging.structured_logger import StructuredLogger
from faststack_core.middleware.correlation_id import CorrelationIdMiddleware
from faststack_core.middleware.request_logging import RequestLoggingMiddleware
from faststack_core.middleware.security_headers import SecurityHeadersMiddleware
from faststack_core.settings.config import FastStackConfig


def setup_app(app: FastAPI, config: FastStackConfig | None = None) -> None:
"""One-call setup for all FastStack middleware, handlers, and health checks.

Each component can be individually disabled via the config.

Middleware order note: Starlette processes middleware in reverse order of
registration. We register security_headers first (outermost = last to
execute on request, first on response), then request_logging, then
correlation_id (innermost = first to execute on request). This means
correlation_id is set before request_logging runs, so logs have the ID.
"""
if config is None:
config = FastStackConfig()

# Logging
logger = StructuredLogger()
logger.setup(log_level=config.log_level)

# Middleware (order matters — outermost first)
if config.security_headers:
app.add_middleware(SecurityHeadersMiddleware)

if config.request_logging:
app.add_middleware(RequestLoggingMiddleware)

if config.correlation_id:
app.add_middleware(CorrelationIdMiddleware)

if config.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Exception handlers
if config.exception_handlers:
from faststack_core.exceptions.handlers import register_exception_handlers

register_exception_handlers(app)

# Health checks
if config.health_check:
health_router = create_health_router(app_version=config.app_version)
app.include_router(health_router)
30 changes: 30 additions & 0 deletions tests/test_core/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Tests for health check endpoints."""

from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient

from faststack_core.health.endpoints import create_health_router


def _make_app(version: str = "1.0.0") -> FastAPI:
app = FastAPI()
app.include_router(create_health_router(app_version=version))
return app


async def test_health_returns_ok():
app = _make_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}


async def test_health_detailed_includes_version():
app = _make_app(version="2.5.0")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/health/detailed")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["version"] == "2.5.0"
Loading
Loading