"""FastAPI service exposing telemetry data from TimescaleDB over HTTPS. Requires mutual TLS: clients must present a certificate signed by the CA. Requests are served from a pool of database connections, so each request reuses an established session instead of paying connect/auth costs. """ import os from contextlib import asynccontextmanager from typing import Any, Final, Optional import psycopg from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse from psycopg import sql from psycopg.rows import dict_row from psycopg_pool import ConnectionPool _SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry" # Connections use row_factory=dict_row, so each row is a dict. _DictConnection = psycopg.Connection[dict[str, Any]] pool: Optional[ConnectionPool[_DictConnection]] = None def _connect_kwargs() -> dict[str, Any]: return { "host": os.getenv("DB_HOST", "timescaledb"), "port": int(os.getenv("DB_PORT", "5432")), "dbname": os.getenv("DB_NAME", "telemetry"), "user": os.getenv("DB_USER", "ingester"), "password": os.getenv("DB_PASSWORD", "changeme"), "row_factory": dict_row, } @asynccontextmanager async def lifespan(app: FastAPI): global pool # Subscripting solves the pool's invariant connection type; row_factory # in kwargs makes every pooled connection return dict rows. server_pool = ConnectionPool[_DictConnection]( kwargs=_connect_kwargs(), min_size=int(os.getenv("DB_POOL_MIN_SIZE", "2")), max_size=int(os.getenv("DB_POOL_MAX_SIZE", "10")), ) server_pool.open(wait=False) pool = server_pool try: yield finally: pool = None server_pool.close() app = FastAPI(title="MQTT Telemetry API", lifespan=lifespan) def get_pool() -> ConnectionPool[_DictConnection]: if pool is None: raise RuntimeError("Database pool not initialized") return pool @app.middleware("http") async def require_client_cert(request: Request, call_next): # uvicorn populates the client cert into the transport when mTLS is enabled. cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)( "peercert" ) if cert is None: # Return a response directly: HTTPException raised from middleware is # not handled by the (inner) exception handlers and would surface # as a 500. return JSONResponse(status_code=401, content={"detail": "Client certificate required"}) return await call_next(request) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} def _telemetry_query( topic: Optional[str], device_id: Optional[str], limit: int ) -> tuple[sql.Composed, list[Any]]: """Build the SELECT for the requested filters as composed SQL. The statement is composed with psycopg.sql, and all values are bound as parameters, so nothing is interpolated into the query string. """ conditions: list[sql.Composable] = [] params: list[Any] = [] if topic: conditions.append(sql.SQL("topic = %s")) params.append(topic) if device_id: conditions.append(sql.SQL("device_id = %s")) params.append(device_id) query = sql.SQL(_SELECT_SQL) if conditions: query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions) query += sql.SQL(" ORDER BY time DESC LIMIT %s") params.append(limit) return query, params @app.get("/telemetry") def list_telemetry( topic: Optional[str] = Query(default=None), device_id: Optional[str] = Query(default=None), limit: int = Query(default=100, le=1000), ) -> list[dict[str, Any]]: query, params = _telemetry_query(topic, device_id, limit) with get_pool().connection() as conn, conn.cursor() as cur: cur.execute(query, params) return cur.fetchall() @app.get("/telemetry/latest") def latest_telemetry( topic: Optional[str] = Query(default=None), device_id: Optional[str] = Query(default=None), ) -> Optional[dict[str, Any]]: query, params = _telemetry_query(topic, device_id, 1) with get_pool().connection() as conn, conn.cursor() as cur: cur.execute(query, params) row = cur.fetchone() if row is None: raise HTTPException(status_code=404, detail="No data found") return row