Source code for exlab_wizard.api.routers.config

"""``/config`` router. Backend Spec §4.6.1, §4.9.

Endpoints:

* ``GET /config`` -- return the current ``config.yaml`` (always
  available; secrets stripped).
* ``PUT /config`` -- validate + persist new config; re-evaluate setup
  state.

Both endpoints are exempt from the setup-state gate by design (Backend
Spec §4.9.2: the operator needs a way to fix an incomplete config).
"""

from __future__ import annotations

import inspect
from typing import Any

from fastapi import APIRouter, Request
from pydantic import BaseModel, ConfigDict

from exlab_wizard.api._dependencies import require_deps
from exlab_wizard.config.models import Config
from exlab_wizard.constants import SetupState
from exlab_wizard.logging import get_logger
from exlab_wizard.paths import (
    evaluate_setup_state,
    setup_state_missing,
    setup_state_next_action,
)

__all__ = ["ConfigUpdateResponse", "build_config_router"]

_log = get_logger(__name__)

# Field paths (dotted notation) that must never appear in a GET /config
# response. The Pydantic model never stores secrets directly (passwords
# live in the keyring), but the redaction list is encoded here so adding
# a future secret field is a one-line change.
_REDACTED_FIELDS: frozenset[str] = frozenset()


[docs] class ConfigUpdateResponse(BaseModel): """``PUT /config`` response with the new setup state.""" model_config = ConfigDict(extra="forbid") state: str missing: list[dict[str, str]] next_action: str | None ready: bool
[docs] def build_config_router() -> APIRouter: """Construct the ``/config`` router. Routes are always available.""" router = APIRouter(tags=["config"]) @router.get("/config", response_model=Config) async def get_config(request: Request) -> Config: deps = require_deps(request) config = getattr(deps, "config", None) if config is None: # Empty default config is the right shape when no config.yaml # exists on disk. Frontend treats this the same as # INCOMPLETE_NO_CONFIG. return Config() return _redact(config) @router.put("/config", response_model=ConfigUpdateResponse) async def put_config(request: Request, body: Config) -> ConfigUpdateResponse: deps = require_deps(request) # Persist via the host-supplied saver (loader.save_config in # production). Tests can substitute a no-op. saver = getattr(deps, "save_config", None) if saver is not None: await _await_or_call(saver, body) deps.config = body # Re-evaluate setup state with the new config. state = evaluate_setup_state( deps.config, lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=getattr(deps, "keyring_password_present", True), ) return ConfigUpdateResponse( state=state.value, missing=setup_state_missing(state, deps.config), next_action=setup_state_next_action(state), ready=state is SetupState.READY, ) return router
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _redact(config: Config) -> Config: """Return ``config`` with secret fields blanked out. The current ``Config`` model carries no in-band secrets (LIMS / NAS passwords live in the keyring), so this is a no-op pass-through. The function is kept so the redaction policy lives in one place; when future fields land they are added to :data:`_REDACTED_FIELDS` and zeroed here. """ if not _REDACTED_FIELDS: return config return config async def _await_or_call(callable_: Any, *args: Any) -> Any: """Invoke a saver that may be sync or async.""" result = callable_(*args) if inspect.isawaitable(result): return await result return result