Files
han-app/codebase/backend/sms-service/app/main.py
T

333 lines
11 KiB
Python

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.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 add_trace_context, init_telemetry, instrument_fastapi
log = structlog.get_logger()
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
]
)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
telemetry = init_telemetry()
configure_logging(settings.log_level)
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.exception("request.failed", error_code="internal_error")
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,
)