from __future__ import annotations import base64 import binascii import hmac import logging import time import uuid from contextlib import asynccontextmanager from typing import Annotated, Any import structlog import uvicorn from fastapi import Body, Depends, FastAPI, Header, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from pydantic import ValidationError from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from starlette.exceptions import HTTPException as StarletteHTTPException from app.db import Database, SmsTemplate from app.domain import DomainError from app.logging_security import redact_event from app.metrics import CALLBACK_LAG, CALLBACK_TOTAL from app.schemas import CallbackItem, ErrorEnvelope, MessageResponse, SendRequest, SendResponse from app.service import ( apply_callback, create_order, load_runtime_settings, read_message, ) from app.settings import get_settings from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, instrument_fastapi log = structlog.get_logger() def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None: logging.basicConfig(level=level, format="%(message)s") if telemetry and telemetry.logging_handler not in logging.getLogger().handlers: telemetry.logging_handler.addFilter( lambda record: not record.name.startswith("opentelemetry") ) logging.getLogger().addHandler(telemetry.logging_handler) structlog.configure( processors=[ structlog.contextvars.merge_contextvars, add_trace_context, redact_event, structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"), structlog.stdlib.add_log_level, structlog.processors.JSONRenderer(), ], logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() telemetry = init_telemetry() configure_logging(settings.log_level, telemetry) app.state.settings = settings app.state.db = Database(settings.database_url) try: yield finally: await app.state.db.close() if telemetry: telemetry.shutdown() app = FastAPI( title="HAN SMS Service", version="1.0.0", openapi_version="3.1.0", docs_url=None, redoc_url=None, lifespan=lifespan, ) @app.middleware("http") async def request_context(request: Request, call_next: Any) -> Response: supplied = request.headers.get("X-Request-ID", "").strip() request_id = supplied[:128] if supplied and supplied.isprintable() else str(uuid.uuid4()) request.state.request_id = request_id started = time.monotonic() structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars( request_id=request_id, method=request.method, **{"service.name": "sms-service"}, ) response = await call_next(request) response.headers["X-Request-ID"] = request_id response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Cache-Control"] = "no-store" log.info( "request.complete", route=getattr(request.scope.get("route"), "path", request.url.path), status_code=response.status_code, duration_ms=round((time.monotonic() - started) * 1000, 2), ) return response def error_response( request: Request, code: str, message: str, status: int, details: dict[str, Any] | list[dict[str, Any]] | None = None, ) -> JSONResponse: return JSONResponse( status_code=status, content={ "error": { "code": code, "message": message, "request_id": getattr(request.state, "request_id", str(uuid.uuid4())), "details": details or {}, } }, ) @app.exception_handler(DomainError) async def domain_error(request: Request, exc: DomainError) -> JSONResponse: response = error_response(request, exc.code, exc.message, exc.status, exc.details) if "retry_after" in exc.details: response.headers["Retry-After"] = str(exc.details["retry_after"]) return response @app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: details = [ {"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]} for item in exc.errors() ] log.warning("request.validation_failed", details=details) return error_response( request, "sms_request_invalid", "SMS request validation failed", 422, details ) @app.exception_handler(StarletteHTTPException) async def http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse: code = "not_found" if exc.status_code == 404 else "method_not_allowed" return error_response(request, code, "Resource was not found", exc.status_code) @app.exception_handler(Exception) async def unhandled_error(request: Request, exc: Exception) -> JSONResponse: log.error( "request.failed", error_code="internal_error", error_type=type(exc).__name__, ) return error_response(request, "internal_error", "Internal server error", 500) async def session(request: Request): async for value in request.app.state.db.session(): yield value Session = Annotated[AsyncSession, Depends(session)] async def bearer_auth(request: Request) -> None: authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): raise DomainError("unauthorized", 401, "Authentication failed") supplied = authorization.removeprefix("Bearer ").strip() expected = request.app.state.settings.service_token.get_secret_value() if not supplied or not hmac.compare_digest(supplied, expected): raise DomainError("unauthorized", 401, "Authentication failed") InternalAuth = Annotated[None, Depends(bearer_auth)] def basic_auth(request: Request) -> None: authorization = request.headers.get("Authorization", "") encoded = ( authorization.removeprefix("Basic ").strip() if authorization.startswith("Basic ") else "" ) try: decoded = base64.b64decode(encoded, validate=True).decode("utf-8") username, password = decoded.split(":", 1) except (binascii.Error, UnicodeDecodeError, ValueError): raise DomainError("unauthorized", 401, "Authentication failed") from None settings = request.app.state.settings valid_user = hmac.compare_digest(username, settings.callback_username.get_secret_value()) valid_password = hmac.compare_digest(password, settings.callback_password.get_secret_value()) if not (valid_user and valid_password): raise DomainError("unauthorized", 401, "Authentication failed") CallbackAuth = Annotated[None, Depends(basic_auth)] @app.get("/health/live", tags=["health"]) async def live() -> dict[str, str]: return {"status": "live"} @app.get("/health/ready", tags=["health"]) async def ready(db: Session) -> JSONResponse: components = { "postgres": "failed", "schema": "failed", "settings": "failed", "template": "failed", } try: await db.execute(text("SELECT 1")) components["postgres"] = "ok" revision = await db.scalar(text("SELECT version_num FROM sms.alembic_version LIMIT 1")) if revision != "0002_seed": raise RuntimeError("unexpected sms schema revision") components["schema"] = "ok" runtime = await load_runtime_settings(db) components["settings"] = "ok" template_count = await db.scalar( select(func.count(SmsTemplate.id)).where( SmsTemplate.code == "auth_otp", SmsTemplate.is_active.is_(True), SmsTemplate.approved_at.is_not(None), (SmsTemplate.sender_name.is_not(None)) | (text(":sender <> ''").bindparams(sender=runtime.default_sender_name)), ) ) if template_count != 1: raise RuntimeError("active approved auth_otp template is missing") components["template"] = "ok" except Exception: log.warning("readiness.failed") failed = "failed" in components.values() return JSONResponse( {"status": "not_ready" if failed else "ready", "components": components}, status_code=503 if failed else 200, ) @app.get("/metrics", include_in_schema=False) async def metrics() -> Response: return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) @app.post( "/internal/sms/v1/send", response_model=SendResponse, responses={ 401: {"model": ErrorEnvelope}, 409: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}, 429: {"model": ErrorEnvelope}, 503: {"model": ErrorEnvelope}, }, tags=["internal"], ) async def send( body: SendRequest, request: Request, db: Session, _auth: InternalAuth, x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None, traceparent: Annotated[ str | None, Header(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$"), ] = None, ) -> JSONResponse: result, created = await create_order( db, body, x_request_id, traceparent, request.app.state.settings.service_token.get_secret_value().encode(), ) return JSONResponse(result.model_dump(mode="json"), status_code=202 if created else 200) @app.get( "/internal/sms/v1/messages/{sms_message_id}", response_model=MessageResponse, responses={401: {"model": ErrorEnvelope}, 404: {"model": ErrorEnvelope}}, tags=["internal"], ) async def message(sms_message_id: uuid.UUID, db: Session, _auth: InternalAuth) -> MessageResponse: return await read_message(db, sms_message_id) @app.post( "/callbacks/idgtl/sms", status_code=204, responses={401: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}}, tags=["callback"], ) async def callback( payload: Annotated[list[dict[str, Any]], Body(min_length=1, max_length=1000)], request: Request, db: Session, _auth: CallbackAuth, ) -> Response: valid_count = 0 for raw in payload: try: item = CallbackItem.model_validate(raw) except ValidationError: CALLBACK_TOTAL.labels("idgtl", "invalid").inc() log.warning("callback.rejected", reason="schema_invalid") continue accepted = await apply_callback(db, item) CALLBACK_TOTAL.labels("idgtl", "accepted" if accepted else "rejected").inc() if accepted: valid_count += 1 lag = max(0.0, (datetime_now() - item.status_time).total_seconds()) CALLBACK_LAG.labels("idgtl", item.status.lower()).observe(lag) await db.commit() return Response(status_code=204, headers={"X-Callback-Items-Accepted": str(valid_count)}) def datetime_now(): from datetime import UTC, datetime return datetime.now(UTC) instrument_fastapi(app) def run() -> None: settings = get_settings() uvicorn.run( "app.main:app", host="0.0.0.0", # noqa: S104 - required container listener port=settings.api_port, proxy_headers=False, )