e757f4ba21
The credential tester (POST /api/services/test) used only body.secrets — the
values typed in the form. When editing an existing service the secret fields
are masked and intentionally left blank ("leave blank to keep current"), so the
test ran with empty credentials and failed auth even though the stored secret
was valid.
When body.id is set, look up the stored service, decrypt its secrets, and fall
back to the stored value for any known secret key that is absent or blank in
the input. The test still uses the freshly-typed config (so you can test an
edited URL) but authenticates with the effective credentials. New-service tests
(no id) are unchanged.
Test: editing a service and testing with empty secrets now authenticates with
the stored secret (asserts the stored key reaches the upstream request).
402/402 backend pass; ruff clean.
230 lines
8.2 KiB
Python
230 lines
8.2 KiB
Python
"""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 TestResult, 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"}
|
|
|
|
|
|
@router.post("/test")
|
|
def test_instance(
|
|
body: ServiceInstanceInput,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Test connectivity + credentials for unsaved service input.
|
|
|
|
Validates first (422 on malformed config), dispatches to the per-type
|
|
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
|
|
"""
|
|
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
|
|
definition = require_service_definition(body.service_type)
|
|
|
|
# When editing an existing service, secret fields are masked and not
|
|
# re-entered (the UI says "leave blank to keep current"), so body.secrets
|
|
# only carries freshly-typed values. Fall back to the stored (decrypted)
|
|
# secret for any known key that is absent or blank, so the test runs with
|
|
# the effective credentials rather than failing auth on empty fields.
|
|
secrets = dict(body.secrets)
|
|
if body.id:
|
|
existing = store.get_service(body.id)
|
|
if existing and existing.get("service_type") == body.service_type:
|
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
|
|
|
stored: dict[str, str] = {}
|
|
try:
|
|
stored = decrypt_secrets(existing.get("secrets") or {})
|
|
except Exception:
|
|
logger.exception("failed to decrypt stored secrets for test service_id=%s", body.id)
|
|
for key in definition.secret_keys:
|
|
if not secrets.get(key) and stored.get(key):
|
|
secrets[key] = stored[key]
|
|
|
|
if definition.test_callable is None:
|
|
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
|
|
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
|
|
|
|
try:
|
|
result: TestResult = definition.test_callable(body.config, secrets, store)
|
|
except Exception as exc:
|
|
logger.exception("test_callable raised for type=%s", body.service_type)
|
|
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
|
|
|
|
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
|
|
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}
|