FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c)

Add api/ with a FastAPI app that imports the bundled NestQuest core
(Feature 15) WITHOUT importing homeassistant: api/nestquest_core.py
bootstraps the integration directory onto sys.path and imports core as
a top-level package, so custom_components/nestquest/__init__.py (which
imports HA) never executes. This transitional coupling is encapsulated
in one documented helper to revisit in Feature 18.

The app reads NESTQUEST_DB_PATH (env var, documented temp default),
opens+migrates the SQLite database in a FastAPI lifespan, and serves
GET /health (200, status ok + resolved db_path) and the OpenAPI doc at
/docs and /openapi.json. Tests start the app with httpx against the
ASGI app and assert all three; a subprocess test proves the app
imports core without importing homeassistant or registering
custom_components.

Add fastapi, uvicorn, httpx to the dev dependency group; uv lock.

Suite green at 1581 (1576 baseline + 5 new API tests).
This commit is contained in:
2026-09-21 16:06:31 -04:00
parent f71124c3ed
commit 70dce8a536
8 changed files with 826 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
"""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 carrying
``status: "ok"`` and the resolved database path, proving the app
imported core, opened the database, and applied migrations.
- ``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.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.config import ApiConfig
from api.database import DatabaseState, make_database
LOGGER = logging.getLogger(__name__)
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.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
state = make_database(db_path)
app.state.db = state
try:
await state.open_and_migrate()
yield
finally:
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 api.app:app`` picks up ``NESTQUEST_DB_PATH``. Tests pass an
explicit config (or set the env var) 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),
)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
"""Return service health and the resolved database path.
The body carries ``status: "ok"`` and the ``db_path`` the app
opened against — so a probe can confirm the app wired core and
reached the configured SQLite file. Returns 200 once the
lifespan has opened and migrated the database.
"""
state: DatabaseState = app.state.db
return {"status": "ok", "db_path": state.db_path}
return app
#: The module-level app instance, for ``uvicorn api.app:app``.
app: FastAPI = create_app()
__all__ = ["app", "create_app"]