111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hmac
|
|
import logging
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from aiohttp import web
|
|
|
|
from .config import Settings
|
|
from .db import CloudSqlClaimReader
|
|
|
|
logger = logging.getLogger("teleo.observatory_read_adapter")
|
|
|
|
SETTINGS_KEY = web.AppKey("observatory_settings", Settings)
|
|
READER_KEY = web.AppKey("observatory_reader", object)
|
|
PUBLIC_PATHS = frozenset({"/healthz", "/readyz"})
|
|
|
|
|
|
def _private_json(payload: dict[str, Any], *, status: int = 200) -> web.Response:
|
|
response = web.json_response(payload, status=status)
|
|
response.headers["Cache-Control"] = "private, no-store, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Vary"] = "X-Api-Key"
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
return response
|
|
|
|
|
|
@web.middleware
|
|
async def api_key_auth(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
if request.path in PUBLIC_PATHS:
|
|
return await handler(request)
|
|
settings = request.app[SETTINGS_KEY]
|
|
provided = request.headers.get("X-Api-Key", "")
|
|
try:
|
|
authenticated = hmac.compare_digest(
|
|
provided.encode("ascii"),
|
|
settings.api_key.encode("ascii"),
|
|
)
|
|
except UnicodeEncodeError:
|
|
authenticated = False
|
|
if not authenticated:
|
|
return _private_json({"error": "unauthorized"}, status=401)
|
|
return await handler(request)
|
|
|
|
|
|
async def healthz(_request: web.Request) -> web.Response:
|
|
return web.json_response({"status": "ok"})
|
|
|
|
|
|
async def readyz(request: web.Request) -> web.Response:
|
|
reader = request.app[READER_KEY]
|
|
try:
|
|
await asyncio.wait_for(asyncio.to_thread(reader.check_ready), timeout=12.0)
|
|
except Exception as exc:
|
|
logger.warning("readiness check failed (%s)", type(exc).__name__)
|
|
return web.json_response({"status": "unavailable"}, status=503)
|
|
return web.json_response({"status": "ready"})
|
|
|
|
|
|
async def get_sample_claim(request: web.Request) -> web.Response:
|
|
return await _claim_response(request, None)
|
|
|
|
|
|
async def get_claim(request: web.Request) -> web.Response:
|
|
raw_claim_id = request.match_info["claim_id"]
|
|
try:
|
|
claim_id = str(uuid.UUID(raw_claim_id))
|
|
except ValueError:
|
|
return _private_json({"error": "invalid_claim_id"}, status=400)
|
|
return await _claim_response(request, claim_id)
|
|
|
|
|
|
async def _claim_response(request: web.Request, claim_id: str | None) -> web.Response:
|
|
reader = request.app[READER_KEY]
|
|
try:
|
|
payload = await asyncio.wait_for(
|
|
asyncio.to_thread(reader.read_claim, claim_id),
|
|
timeout=12.0,
|
|
)
|
|
except Exception as exc:
|
|
logger.error("canonical claim read failed (%s)", type(exc).__name__)
|
|
return _private_json({"error": "canonical_claim_unavailable"}, status=503)
|
|
if payload is None:
|
|
return _private_json({"error": "claim_not_found"}, status=404)
|
|
return _private_json(payload)
|
|
|
|
|
|
async def _cleanup(app: web.Application) -> None:
|
|
reader = app[READER_KEY]
|
|
await asyncio.to_thread(reader.close)
|
|
|
|
|
|
def create_app(
|
|
settings: Settings | None = None,
|
|
*,
|
|
reader: Any | None = None,
|
|
) -> web.Application:
|
|
settings = settings or Settings.from_env()
|
|
settings.validate()
|
|
reader = reader or CloudSqlClaimReader(settings)
|
|
app = web.Application(middlewares=[api_key_auth], client_max_size=16 * 1024)
|
|
app[SETTINGS_KEY] = settings
|
|
app[READER_KEY] = reader
|
|
app.router.add_get("/healthz", healthz)
|
|
app.router.add_get("/readyz", readyz)
|
|
app.router.add_get("/v1/claims/sample", get_sample_claim)
|
|
app.router.add_get("/v1/claims/{claim_id}", get_claim)
|
|
app.on_cleanup.append(_cleanup)
|
|
return app
|