2026-09-21 16:13:41 -04:00
|
|
|
"""Configuration for the NestQuest API service.
|
|
|
|
|
|
2026-09-22 22:16:27 -04:00
|
|
|
The API reads its configuration knobs from environment variables:
|
|
|
|
|
|
|
|
|
|
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
|
|
|
|
|
file on startup (creating it if absent) and applies migrations.
|
|
|
|
|
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
|
|
|
|
|
(D-012): the credential ``/api/v1/panel/*`` routes require via
|
|
|
|
|
:func:`api.dependencies.require_panel_token`. It is stored outside
|
|
|
|
|
the repo and handed to the service through the environment.
|
|
|
|
|
|
|
|
|
|
Unlike a dev convenience default, a missing or empty value is a
|
|
|
|
|
configuration ERROR, not a silent fallback: a default database path
|
|
|
|
|
would silently drop data at reboot, and a default (or empty) service
|
|
|
|
|
token would either authenticate everyone or lock the panel plane out
|
|
|
|
|
for good. ``ApiConfig.from_env`` therefore RAISES when either variable
|
|
|
|
|
is unset or empty. This mirrors the integration's "explicit settings
|
|
|
|
|
object" discipline (Feature 15's ``core.settings``): the API never
|
|
|
|
|
reads HA config; callers pass the resolved values into the wiring.
|
2026-09-21 16:13:41 -04:00
|
|
|
|
|
|
|
|
Serving: the app is built with the factory ``api.app:create_app`` — run
|
|
|
|
|
it with ``uvicorn --factory api.app:create_app`` so the factory reads
|
|
|
|
|
the environment on each worker startup rather than at import time.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
#: Environment variable holding the SQLite file path. The API opens
|
|
|
|
|
#: this file on startup (creating it if absent) and applies migrations.
|
|
|
|
|
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
|
|
|
|
#: :meth:`ApiConfig.from_env`.
|
|
|
|
|
DB_PATH_ENV = "NESTQUEST_DB_PATH"
|
|
|
|
|
|
2026-09-22 22:16:27 -04:00
|
|
|
#: Environment variable holding the static panel-plane service token
|
|
|
|
|
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
|
|
|
|
|
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
|
|
|
|
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
|
|
|
|
|
#: empty configured token would either authenticate every caller or
|
|
|
|
|
#: lock the panel plane out permanently.
|
|
|
|
|
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
|
|
|
|
|
|
2026-09-21 16:13:41 -04:00
|
|
|
|
|
|
|
|
class ConfigError(RuntimeError):
|
|
|
|
|
"""Raised when required API configuration is missing or invalid."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ApiConfig:
|
|
|
|
|
"""Resolved API configuration.
|
|
|
|
|
|
2026-09-22 22:16:27 -04:00
|
|
|
Both fields are required (no defaults): ``from_env`` rejects an
|
|
|
|
|
unset or empty value for either, and a caller building the dataclass
|
|
|
|
|
directly must pass an explicit token — the token check itself fails
|
|
|
|
|
closed on an empty configured token, but expecting a caller to
|
|
|
|
|
discover that at request time is a wiring mistake, so the field is
|
|
|
|
|
mandatory. Kept as a dataclass so later tasks (admin plane, SSE,
|
|
|
|
|
settings persistence) can extend it without touching the call sites
|
|
|
|
|
that read ``db_path``.
|
2026-09-21 16:13:41 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
db_path: str
|
2026-09-22 22:16:27 -04:00
|
|
|
panel_token: str
|
2026-09-21 16:13:41 -04:00
|
|
|
|
|
|
|
|
@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).
|
|
|
|
|
|
|
|
|
|
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
|
|
|
|
|
or empty/whitespace-only — there is no silent default, because a
|
2026-09-22 22:16:27 -04:00
|
|
|
default in the temp directory would silently lose data at reboot
|
|
|
|
|
— and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty
|
|
|
|
|
(a default token is never invented: the panel plane is closed
|
|
|
|
|
until a real one is configured).
|
2026-09-21 16:13:41 -04:00
|
|
|
"""
|
|
|
|
|
source = env if env is not None else os.environ
|
2026-09-22 22:16:27 -04:00
|
|
|
raw_db_path = source.get(DB_PATH_ENV, "").strip()
|
|
|
|
|
if not raw_db_path:
|
2026-09-21 16:13:41 -04:00
|
|
|
raise ConfigError(
|
|
|
|
|
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
|
|
|
|
|
f"(it is unset or empty — the API will not fall back to a "
|
|
|
|
|
f"temp directory and silently lose data)"
|
|
|
|
|
)
|
2026-09-22 22:16:27 -04:00
|
|
|
raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip()
|
|
|
|
|
if not raw_panel_token:
|
|
|
|
|
raise ConfigError(
|
|
|
|
|
f"{PANEL_TOKEN_ENV} must be set to the panel service token "
|
|
|
|
|
f"(it is unset or empty — the API will not start without "
|
|
|
|
|
f"one, and no default token is invented)"
|
|
|
|
|
)
|
|
|
|
|
return cls(db_path=raw_db_path, panel_token=raw_panel_token)
|
2026-09-21 16:13:41 -04:00
|
|
|
|
|
|
|
|
|
2026-09-22 22:16:27 -04:00
|
|
|
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"]
|