🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-08-30 09:56:33 +02:00
commit c2ae24818e
14 changed files with 1058 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
"""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