"""REST API for the service registry. Service instances hold non-secret config and encrypted secrets. Plaintext secrets are never returned; only the boolean ``secrets_set`` map is exposed. """ from __future__ import annotations import logging from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.integrations.base import validate_config from media_library_viewer_api.integrations.registry import ( SERVICE_DEFINITIONS, get_service_definition, require_service_definition, ) from media_library_viewer_api.models.services import ( SecretFieldInfo, ServiceInstance, ServiceInstanceInput, ServiceTypeInfo, WidgetKindInfo, ) from media_library_viewer_api.services.settings_store import SettingsStore router = APIRouter(prefix="/api/services", tags=["services"]) logger = logging.getLogger(__name__) def _to_type_info(service_type: str) -> ServiceTypeInfo: definition = require_service_definition(service_type) return ServiceTypeInfo( service_type=definition.service_type, name=definition.name, description=definition.description, config_schema=definition.config_schema, secret_fields=[ SecretFieldInfo( key=sf.key, label=sf.label, required=sf.required, helper=sf.helper, ) for sf in definition.secret_fields ], widget_kinds=[ WidgetKindInfo( kind=wk.kind, name=wk.name, description=wk.description, config_schema=wk.config_schema, default_config=wk.default_config, refresh_interval_ms=wk.refresh_interval_ms, ) for wk in definition.widget_kinds ], ) def _to_instance(row: dict[str, Any]) -> ServiceInstance: """Build an API response model, surfacing only secret 'set' flags.""" definition = get_service_definition(row["service_type"]) known_secrets = definition.secret_keys if definition else set() secrets_blob = row.get("secrets") or {} secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets} return ServiceInstance( id=row["id"], service_type=row["service_type"], name=row["name"], config=row.get("config") or {}, secrets_set=secrets_set, enabled=row["enabled"], created_at=row["created_at"], updated_at=row["updated_at"], ) def _validate_input(body: ServiceInstanceInput) -> None: """Validate service_type, config, and secret keys against the definition.""" definition = get_service_definition(body.service_type) if definition is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Unknown service type: {body.service_type}", ) try: validate_config(definition.config_model, body.config) except Exception as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Invalid service config: {exc}", ) from exc unknown_secrets = set(body.secrets) - definition.secret_keys if unknown_secrets: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}", ) @router.get("/types") def list_types() -> list[ServiceTypeInfo]: """Return metadata for every registered service type.""" return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)] @router.get("/instances") def list_instances( service_type: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[ServiceInstance]: """Return all persisted service instances (no plaintext secrets).""" rows = store.list_services(service_type) return [_to_instance(row) for row in rows] @router.post("/instances", status_code=status.HTTP_201_CREATED) def create_instance( body: ServiceInstanceInput, store: SettingsStore = Depends(get_settings_store), ) -> ServiceInstance: """Create a new service instance.""" _validate_input(body) row = store.upsert_service( { "id": body.id, "service_type": body.service_type, "name": body.name, "config": body.config, "enabled": body.enabled, }, secret_values=body.secrets, ) return _to_instance(row) @router.put("/instances/{service_id}") def update_instance( service_id: str, body: ServiceInstanceInput, store: SettingsStore = Depends(get_settings_store), ) -> ServiceInstance: """Update an existing service instance.""" existing = store.get_service(service_id) if not existing: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found") if body.id is not None and body.id != service_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="ID in path does not match ID in body", ) _validate_input(body) row = store.upsert_service( { "id": service_id, "service_type": body.service_type, "name": body.name, "config": body.config, "enabled": body.enabled, }, secret_values=body.secrets, service_id=service_id, ) return _to_instance(row) @router.delete("/instances/{service_id}") def delete_instance( service_id: str, store: SettingsStore = Depends(get_settings_store), ) -> dict[str, str]: """Delete a service instance (cascade-deletes widgets referencing it).""" existing = store.get_service(service_id) if not existing: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found") store.delete_service(service_id) return {"status": "deleted"}