M2-T01: add config JSON API (GET/PUT /api/config)
- new app/api/routes/api/ package with shared require_session (401) and require_csrf (presence-only X-CSRF-Token, 403) dependencies - GET /api/config returns masked config sections; PUT /api/config reuses save_config_updates (blank secret keeps old; invalid -> 422, no write) - session-protected; PUT also CSRF-protected - register router in app/main.py; regenerate openapi/ - tests/test_api_config.py
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.schemas.config import (
|
||||
ConfigField,
|
||||
ConfigResponse,
|
||||
ConfigSection,
|
||||
ConfigUpdateRequest,
|
||||
ConfigUpdateResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-config"])
|
||||
|
||||
|
||||
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
|
||||
result = []
|
||||
for section in sections_raw:
|
||||
fields = [ConfigField(**f) for f in section["fields"]]
|
||||
result.append(ConfigSection(name=section["name"], fields=fields))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigResponse)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ConfigResponse:
|
||||
"""Return all configuration sections. Secret field values are masked (empty string)."""
|
||||
sections_raw = build_config_sections(db, settings)
|
||||
return ConfigResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@router.put("/config", response_model=ConfigUpdateResponse)
|
||||
def put_config(
|
||||
body: ConfigUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ConfigUpdateResponse:
|
||||
"""
|
||||
Save configuration updates.
|
||||
|
||||
- Blank secret value keeps the existing stored value (no change).
|
||||
- Invalid values return 422 and nothing is written to the database.
|
||||
"""
|
||||
try:
|
||||
save_config_updates(db, body.updates, settings)
|
||||
except ConfigSaveError as exc:
|
||||
logger.warning("Rejected config update via API: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="invalid config submission",
|
||||
) from exc
|
||||
|
||||
# Re-read settings after save (save_config_updates clears the settings cache)
|
||||
refreshed_settings = get_settings()
|
||||
sections_raw = build_config_sections(db, refreshed_settings)
|
||||
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.dependencies import get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
|
||||
def require_session(
|
||||
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> AuthenticatedSession:
|
||||
if auth is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="authentication required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
def require_csrf(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
|
||||
) -> None:
|
||||
if not x_csrf_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="missing CSRF token",
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes import pages, status
|
||||
from app.db import get_session_local
|
||||
@@ -91,6 +92,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigField(BaseModel):
|
||||
env_name: str
|
||||
label: str
|
||||
value: str
|
||||
secret: bool
|
||||
input_type: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ConfigSection(BaseModel):
|
||||
name: str
|
||||
fields: list[ConfigField]
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
|
||||
|
||||
updates: dict[str, str]
|
||||
|
||||
|
||||
class ConfigUpdateResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
Reference in New Issue
Block a user