Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
"""The NestQuest FastAPI application.
|
|
|
|
This module builds the ASGI app for the NestQuest API service (Feature
|
|
16). The skeleton here wires the bundled ``core`` (Feature 15) and
|
|
exposes the two endpoints every later task depends on:
|
|
|
|
- ``GET /health`` — returns ``200`` with a JSON status body once the
|
|
app has imported core, opened the database, and applied migrations;
|
|
a cheap ``SELECT 1`` confirms the connection is live without leaking
|
|
the filesystem path on an unauthenticated route.
|
|
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
|
|
(service-token protected; see :mod:`api.routes_panel`, whose router
|
|
carries the shared :func:`api.dependencies.require_panel_token`
|
|
check).
|
|
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
|
|
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
|
|
router carries the shared :func:`api.auth.require_admin_jwt`
|
|
dependency; the JWKS document it verifies against is cached on
|
|
``app.state.jwks_cache``).
|
|
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
|
|
``/openapi.json`` is the raw schema. No extra wiring is needed;
|
|
FastAPI serves both by default.
|
|
|
|
The database is opened and migrated in a FastAPI lifespan context so a
|
|
startup failure (e.g. an unwritable path) surfaces as a startup error
|
|
rather than the first request. The connection is closed on shutdown.
|
|
|
|
This module exports ONLY the :func:`create_app` factory — there is no
|
|
module-level app instance, because building one at import time would
|
|
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
|
|
before any worker is ready to report it. Serve with::
|
|
|
|
uvicorn --factory api.app:create_app
|
|
|
|
so the factory runs on each worker startup and reads the environment
|
|
then.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from collections.abc import AsyncIterator
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from api.auth import JwksCache
|
|
from api.config import ApiConfig
|
|
from api.database import DatabaseState, make_database
|
|
from api.events import TransitionPublisher
|
|
from api.routes_admin import router as admin_router
|
|
from api.routes_panel import router as panel_router
|
|
from api.scheduler import MissedSweepScheduler
|
|
|
|
|
|
def _build_lifespan(db_path: str):
|
|
"""Return a lifespan that opens+migrates then closes one database.
|
|
|
|
Captured as a closure so the app factory can hand the configured
|
|
path in without threading it through FastAPI's lifespan signature.
|
|
The lifespan also installs the app's ONE in-process transition
|
|
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
|
|
every route and SSE subscriber shares that single instance, and
|
|
starts the daily missed-sweep scheduler
|
|
(:class:`api.scheduler.MissedSweepScheduler`) against the same
|
|
database and publisher — cancelled before the connection closes on
|
|
shutdown.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
state = make_database(db_path)
|
|
app.state.db = state
|
|
app.state.publisher = TransitionPublisher()
|
|
scheduler = MissedSweepScheduler(
|
|
state.database, publisher=app.state.publisher
|
|
)
|
|
app.state.sweep_scheduler = scheduler
|
|
try:
|
|
await state.open_and_migrate()
|
|
scheduler.start()
|
|
yield
|
|
finally:
|
|
await scheduler.stop()
|
|
await state.close()
|
|
|
|
return lifespan
|
|
|
|
|
|
def create_app(config: ApiConfig | None = None) -> FastAPI:
|
|
"""Build the NestQuest API FastAPI app.
|
|
|
|
``config`` defaults to one built from the process environment, so
|
|
``uvicorn --factory api.app:create_app`` picks up
|
|
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
|
|
Tests pass an explicit config so each test gets a hermetic temp
|
|
SQLite file.
|
|
"""
|
|
cfg = config if config is not None else ApiConfig.from_env()
|
|
|
|
app = FastAPI(
|
|
title="NestQuest API",
|
|
description=(
|
|
"Standalone HTTP service that owns the NestQuest database and "
|
|
"exposes it to the Home Assistant integration (panel plane) and "
|
|
"the admin PWA (admin plane)."
|
|
),
|
|
version="0.1.0",
|
|
lifespan=_build_lifespan(cfg.db_path),
|
|
)
|
|
|
|
# The resolved config rides on app.state so the reusable route
|
|
# dependencies (the panel service-token check, the admin JWT check)
|
|
# read the configured values from the app rather than module state.
|
|
app.state.config = cfg
|
|
# The admin plane's ONE per-app JWKS cache: fetched from the
|
|
# configured provider on first use, then reused until its TTL
|
|
# lapses (see api.auth.JwksCache). Tests replace it with a
|
|
# stub-backed cache so no request ever leaves the process.
|
|
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
|
|
app.include_router(panel_router)
|
|
app.include_router(admin_router)
|
|
|
|
@app.get("/health", summary="Service health")
|
|
async def health() -> dict[str, str]:
|
|
"""Return service health.
|
|
|
|
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
|
|
opened and migrated the database. A cheap ``SELECT 1`` against
|
|
the live connection confirms the database is reachable WITHOUT
|
|
leaking the filesystem path on an unauthenticated route; if the
|
|
connection is dead the query raises and the probe fails.
|
|
"""
|
|
state: DatabaseState = app.state.db
|
|
await state.database.fetch_one("SELECT 1")
|
|
return {"status": "ok"}
|
|
|
|
return app
|
|
|
|
|
|
__all__ = ["create_app"]
|