""" Generic plugin proxy. Feature containers advertise themselves via GET /plugin/manifest. The backend health-poller caches those manifests. This router exposes them to the browser through auth-gated endpoints so the frontend never needs to know about specific features. Routes: GET /api/plugins → list accessible plugins for current user GET /api/plugins/{id}/manifest → cached manifest (404 if not accessible) GET /api/plugins/{id}/settings → proxy to feature /plugin/settings PATCH /api/plugins/{id}/settings → proxy to feature /plugin/settings """ import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.deps import check_plugin_access, get_current_user from app.models.user import User from app.services.service_health import _REGISTRY, get_cached_manifest, get_service_url router = APIRouter() _HOP_BY_HOP = frozenset([ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host", "accept-encoding", ]) _STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) async def _proxy(plugin_id: str, method: str, path: str, body: bytes | None, content_type: str | None = None) -> Response: """Forward a request to the feature container's plugin endpoint.""" url = get_service_url(plugin_id) if url is None: raise HTTPException(status_code=404, detail="Not found") headers: dict[str, str] = {} if content_type: headers["content-type"] = content_type try: async with httpx.AsyncClient(base_url=url, timeout=30.0) as client: resp = await client.request(method, path, content=body, headers=headers) except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"Plugin service unreachable: {exc}") resp_headers = {k: v for k, v in resp.headers.items() if k.lower() not in _STRIP_RESPONSE} return Response( content=resp.content, status_code=resp.status_code, headers=resp_headers, media_type=resp.headers.get("content-type", "application/json"), ) @router.get("") async def list_plugins( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[dict]: """Return the list of plugins the current user may access.""" accessible = [] for svc in _REGISTRY: manifest = get_cached_manifest(svc.id) if manifest is None: continue if await check_plugin_access(svc.id, current_user, db): accessible.append({ "id": manifest["id"], "name": manifest["name"], "icon": manifest.get("icon", "package"), "version": manifest.get("version", ""), }) return accessible @router.get("/{plugin_id}/manifest") async def get_plugin_manifest( plugin_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: if not await check_plugin_access(plugin_id, current_user, db): raise HTTPException(status_code=404, detail="Not found") manifest = get_cached_manifest(plugin_id) if manifest is None: raise HTTPException(status_code=404, detail="Not found") return manifest @router.get("/{plugin_id}/settings") async def get_plugin_settings( plugin_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: if not await check_plugin_access(plugin_id, current_user, db): raise HTTPException(status_code=404, detail="Not found") return await _proxy(plugin_id, "GET", "/plugin/settings", None) @router.patch("/{plugin_id}/settings") async def update_plugin_settings( plugin_id: str, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: if not await check_plugin_access(plugin_id, current_user, db): raise HTTPException(status_code=404, detail="Not found") body = await request.body() content_type = request.headers.get("content-type", "application/json") return await _proxy(plugin_id, "PATCH", "/plugin/settings", body, content_type)