🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-08-31 21:13:38 +02:00
commit 26a027d1e8
26 changed files with 1544 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copy into /app/api/ (not /app/): `COPY api/ /app/` would flatten the directory
# contents into /app and `uvicorn api.app:app` would find no api package.
COPY api/requirements.txt /app/api/requirements.txt
RUN pip install --no-cache-dir -r /app/api/requirements.txt
COPY api/ /app/api/
# Run as an unprivileged user.
RUN useradd --system --create-home --uid 10001 appuser
USER appuser
CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""nodeledge api package."""
+234
View File
@@ -0,0 +1,234 @@
"""FastAPI application exposing read access to the mqtt_messages table.
Authentication: static app tokens from a JSON config file, passed either as
the `X-App-Token` header or as an `app_token` query parameter. Tokens are
compared in constant time; invalid tokens yield 401.
"""
from __future__ import annotations
import hmac
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
from psycopg import sql
logger = logging.getLogger("api")
class AppConfig:
"""Runtime configuration loaded once at startup."""
def __init__(self, dsn: str, tokens: dict[str, str], max_limit: int) -> None:
self.dsn = dsn
self.tokens = tokens
self.max_limit = max_limit
def load_tokens(path: str) -> dict[str, str]:
"""Load {"tokens": {"app-name": "secret"}} from a JSON file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict) or not isinstance(data.get("tokens"), dict):
raise ValueError(f"{path}: expected JSON on the form {{\"tokens\": {{name: secret}}}}")
tokens = data["tokens"]
for name, secret in tokens.items():
if not isinstance(secret, str) or not secret:
raise ValueError(f"{path}: token {name!r} must be a non-empty string")
if not tokens:
logger.warning("%s contains no tokens; the API will reject all requests", path)
return dict(tokens)
def _env(name: str, default: str | None = None) -> str:
value = os.environ.get(name)
if not value:
if default is None:
raise RuntimeError(f"missing required environment variable {name}")
return default
return value
def _as_utc(value: datetime) -> datetime:
"""Interpret naive timestamps as UTC (received_at is TIMESTAMPTZ)."""
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
def create_app() -> FastAPI:
app = FastAPI(
title="nodeledge MQTT data API",
version="1.0.0",
description="Read access to MQTT-collected JSON payloads.",
)
tokens_path = _env("APP_TOKENS_FILE")
dsn = _env("DB_DSN")
max_limit = int(_env("API_MAX_LIMIT", "10000"))
config = AppConfig(dsn=dsn, tokens=load_tokens(tokens_path), max_limit=max_limit)
def authenticate(request: Request) -> None:
token = request.headers.get("X-App-Token")
if not token:
token = request.query_params.get("app_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing app token: pass the X-App-Token header",
)
for candidate in config.tokens.values():
if hmac.compare_digest(candidate, token):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid app token",
)
app.state.config = config
app.state.authenticate = authenticate
@app.get("/health", tags=["meta"])
def health() -> dict[str, Any]:
return {"status": "ok"}
@app.get("/messages/count", tags=["meta"], dependencies=[Depends(authenticate)])
def message_count() -> dict[str, int]:
conn = psycopg.connect(config.dsn)
try:
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM mqtt_messages")
row = cur.fetchone()
finally:
conn.close()
count = row[0] if row is not None else 0
return {"count": count}
@app.get("/messages", tags=["data"], dependencies=[Depends(authenticate)])
def list_messages(
imei: str | None = Query(None, description="Filter by IMEI (15 digits)"),
device: str | None = Query(None, description="Filter by device name"),
from_time: datetime | None = Query(
None,
alias="from",
description=(
"Only messages received at or after this time (ISO 8601, inclusive; "
"naive values are treated as UTC)"
),
),
to_time: datetime | None = Query(
None,
alias="to",
description=(
"Only messages received at or before this time (ISO 8601, inclusive; "
"naive values are treated as UTC)"
),
),
payload_from_time: datetime | None = Query(
None,
alias="payload_from",
description=(
"Frame on the device timestamp extracted from the JSON payload "
"(ISO 8601, inclusive; naive values are treated as UTC). Messages "
"without a parseable payload timestamp are excluded."
),
),
payload_to_time: datetime | None = Query(
None,
alias="payload_to",
description=(
"Frame on the device timestamp extracted from the JSON payload "
"(ISO 8601, inclusive; naive values are treated as UTC). Messages "
"without a parseable payload timestamp are excluded."
),
),
limit: int = Query(100, gt=0, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
) -> dict[str, Any]:
if from_time is not None and to_time is not None and from_time > to_time:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="'from' must be earlier than or equal to 'to'",
)
if (
payload_from_time is not None
and payload_to_time is not None
and payload_from_time > payload_to_time
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="'payload_from' must be earlier than or equal to 'payload_to'",
)
limit = min(limit, config.max_limit)
conditions: list[sql.Composable] = []
params: list[Any] = []
if imei:
conditions.append(sql.SQL("imei = %s"))
params.append(imei)
if device:
conditions.append(sql.SQL("device = %s"))
params.append(device)
if from_time is not None:
conditions.append(sql.SQL("received_at >= %s"))
params.append(_as_utc(from_time))
if to_time is not None:
conditions.append(sql.SQL("received_at <= %s"))
params.append(_as_utc(to_time))
if payload_from_time is not None:
conditions.append(sql.SQL("payload_time >= %s"))
params.append(_as_utc(payload_from_time))
if payload_to_time is not None:
conditions.append(sql.SQL("payload_time <= %s"))
params.append(_as_utc(payload_to_time))
where = sql.SQL("")
if conditions:
where = sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
# Order by device timestamp when framing on it (NULLS LAST matches the
# idx_mqtt_messages_ptime index); otherwise newest-first by insert id.
if payload_from_time is not None or payload_to_time is not None:
order_by = sql.SQL(" ORDER BY payload_time DESC NULLS LAST LIMIT %s OFFSET %s")
else:
order_by = sql.SQL(" ORDER BY id DESC LIMIT %s OFFSET %s")
query = sql.SQL(
"SELECT id, received_at, topic, imei, device, payload, payload_time FROM mqtt_messages"
) + where + order_by
conn = psycopg.connect(config.dsn)
try:
with conn.cursor() as cur:
cur.execute(query, (*params, limit, offset))
rows = cur.fetchall()
finally:
conn.close()
return {
"limit": limit,
"offset": offset,
"from": from_time.isoformat() if from_time is not None else None,
"to": to_time.isoformat() if to_time is not None else None,
"payload_from": payload_from_time.isoformat() if payload_from_time is not None else None,
"payload_to": payload_to_time.isoformat() if payload_to_time is not None else None,
"items": [
{
"id": row[0],
"received_at": row[1].isoformat(),
"topic": row[2],
"imei": row[3],
"device": row[4],
"payload": row[5],
"payload_time": row[6].isoformat() if row[6] is not None else None,
}
for row in rows
],
}
return app
app = create_app()
+24
View File
@@ -0,0 +1,24 @@
"""Container healthcheck for the api service: probes GET /health."""
from __future__ import annotations
import sys
import urllib.request
def main() -> int:
try:
with urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=5) as response:
body = response.read().decode("utf-8", errors="replace")
if response.status == 200:
print(body)
return 0
print(f"unexpected status {response.status}: {body}")
return 1
except Exception as exc: # noqa: BLE001 - report any failure as unhealthy
print(f"health check failed: {exc}")
return 1
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
psycopg[binary]==3.2.3