Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user