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
+17
View File
@@ -0,0 +1,17 @@
"""NestQuest API service.
A standalone FastAPI deployable that owns the NestQuest database and
exposes it over HTTP to the Home Assistant integration (panel plane) and
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
project tracker for the full contract.
This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core` (a ``sys.path`` bootstrap that makes ``core``
importable as a top-level package). It is a TRANSITIONAL coupling that
Feature 18 will revisit: once the integration becomes a thin API client
and ``core`` moves out of the integration bundle, this helper goes away.
"""
from __future__ import annotations
+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"]
+61
View File
@@ -0,0 +1,61 @@
"""Configuration for the NestQuest API service.
The API reads its single configuration knob — the SQLite database path —
from the ``NESTQUEST_DB_PATH`` environment variable, with a documented
default inside the OS temp directory. This mirrors the integration's
"explicit settings object" discipline (Feature 15's
``core.settings``): the API never reads HA config; callers pass the
resolved path into the database wiring.
"""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
#: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations.
DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: The default database location when the env var is unset. Lives in
#: the OS temp directory so a dev run without configuration does not
#: litter the repo, and so tests that forget to set the env var still
#: get a hermetic-ish path. Production deployments ALWAYS set
#: ``NESTQUEST_DB_PATH`` to a persistent volume path.
_DEFAULT_DB_NAME = "nestquest.db"
def _default_db_path() -> str:
return str(Path(tempfile.gettempdir()) / _DEFAULT_DB_NAME)
@dataclass(frozen=True)
class ApiConfig:
"""Resolved API configuration.
Currently a single field — the SQLite path — but kept as a dataclass
so later tasks (auth, SSE, settings persistence) can extend it
without touching the call sites that read ``db_path``.
"""
db_path: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
"""Build configuration from the process environment.
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
"""
source = env if env is not None else os.environ
raw = source.get(DB_PATH_ENV, "").strip()
return cls(db_path=raw if raw else _default_db_path())
def resolve_db_path(env: dict[str, str] | None = None) -> str:
"""Return the resolved SQLite path (env override or documented default)."""
return ApiConfig.from_env(env).db_path
__all__ = ["ApiConfig", "DB_PATH_ENV", "resolve_db_path"]
+82
View File
@@ -0,0 +1,82 @@
"""Database wiring for the NestQuest API service.
The API owns the database (it is the writer of record per Feature 16's
contract). This module owns one application-lifetime
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
configured path on startup, applies migrations (creating the schema on
a fresh file), and closes the connection on shutdown.
The executor callable passed into ``NestQuestDatabase`` mirrors the
contract of ``hass.async_add_executor_job``: schedule a synchronous
callable on a worker and await its result. HA's own version lives on
the event loop; the API is not HA, so it uses
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
worker thread and only plain data returns to the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from api.nestquest_core import core_db, core_migrations
LOGGER = logging.getLogger(__name__)
#: The async SQLite wrapper. Re-exported so route handlers and later
#: tasks reach it through the API package, not the bundled core.
NestQuestDatabase = core_db.NestQuestDatabase
#: The migration runner. Re-exported for the same reason.
apply_migrations = core_migrations.apply_migrations
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous callable on a worker thread and await its result.
Mirrors the contract of ``hass.async_add_executor_job`` so the
bundled ``NestQuestDatabase`` (written against that contract) works
unchanged in the API process: sqlite3 calls never block the event
loop, and only plain Python data (row tuples,
:class:`core.db.ExecutionResult`) crosses back.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
class DatabaseState:
"""Holds the live database wrapper and its resolved path.
A small container so the lifespan and the route handlers share one
authoritative reference; FastAPI's lifespan stores one of these on
``app.state.db`` and the health route reads it back.
"""
__slots__ = ("database", "db_path")
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
self.database = database
self.db_path = db_path
async def open_and_migrate(self) -> None:
"""Open the connection and apply migrations to the latest version."""
LOGGER.info("Opening NestQuest database at %s", self.db_path)
await self.database.open(self.db_path)
version = await apply_migrations(self.database)
LOGGER.info("NestQuest schema at version %d", version)
async def close(self) -> None:
"""Close the connection; safe to call even if open failed."""
await self.database.close()
def make_database(db_path: str) -> DatabaseState:
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
return DatabaseState(NestQuestDatabase(_executor), db_path)
__all__ = [
"DatabaseState",
"NestQuestDatabase",
"apply_migrations",
"make_database",
]
+87
View File
@@ -0,0 +1,87 @@
"""Transitional coupling: import ``core`` without importing Home Assistant.
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
Home Assistant integration at ``custom_components/nestquest/core/``.
Importing it the obvious way — ``import custom_components.nestquest.core``
— would execute ``custom_components/nestquest/__init__.py``, which
imports ``homeassistant``. Home Assistant is NOT installed in the API
service's runtime, so that import raises ``ModuleNotFoundError`` and the
API cannot start.
``core`` itself is a clean, Home-Assistant-free package: its
``__init__.py`` is a bare docstring and every internal import is
relative (``from .db import ...``). It can therefore be imported as a
top-level package if the integration directory is placed on
``sys.path``. This module does exactly that, in ONE documented place,
so the coupling between the API and the bundled core is explicit and
testable rather than scattered across route handlers.
Usage::
from api.nestquest_core import core_db, core_migrations
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
the integration into a thin API client and moves ``core`` out of the
integration bundle; once that lands, this bootstrap helper goes away and
the API imports ``core`` as an ordinary installed dependency.
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import ModuleType
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
#: The integration directory that contains the bundled ``core`` package.
#: Adding this to ``sys.path`` makes ``import core`` resolve to
#: ``custom_components/nestquest/core/`` WITHOUT executing the
#: integration's ``__init__.py`` (which would import ``homeassistant``).
_INTEGRATION_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest"
_BOOTSTRAPPED = False
def bootstrap_core() -> None:
"""Make the bundled ``core`` package importable as a top-level package.
Inserts the integration directory at the front of ``sys.path`` (once,
idempotently). After this call, ``import core`` resolves to the
bundled ``custom_components/nestquest/core`` package, and its
relative internal imports (``from .db import ...``) resolve against
that same directory. The integration's HA-coupled ``__init__.py`` is
never executed: ``core`` is imported directly as a top-level package,
so ``custom_components.nestquest`` is never touched.
"""
global _BOOTSTRAPPED
if _BOOTSTRAPPED:
return
path = str(_INTEGRATION_DIR)
if path not in sys.path:
sys.path.insert(0, path)
_BOOTSTRAPPED = True
def _ensure_core() -> ModuleType:
"""Import ``core`` (bootstrapping first) and return the module object."""
bootstrap_core()
import core # type: ignore[import-not-found] # noqa: F401
return core
#: The bundled core package, imported via the sys.path bootstrap. Hold a
#: reference here so callers import the submodules through it
#: (``core.db``, ``core.migrations``) the same way the integration does.
core: ModuleType = _ensure_core()
#: Re-exported for convenience; route handlers import these names from
#: here rather than reaching into ``core`` directly.
core_db = __import__("core.db", fromlist=["db"]) # type: ignore[import-not-found]
core_migrations = __import__( # type: ignore[import-not-found]
"core.migrations", fromlist=["migrations"]
)
__all__ = ["bootstrap_core", "core", "core_db", "core_migrations"]