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:
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user