339 lines
11 KiB
Python
339 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.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 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,
|
|
redact_event,
|
|
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.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,
|
|
)
|