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).
93 lines
3.1 KiB
Python
93 lines
3.1 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 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"]
|