feat: implement governed service directory
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.institutional import (
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_portal.backend.schemas import (
|
||||
PortalServiceLaunchRequest,
|
||||
PortalServiceLaunchResponse,
|
||||
PortalServiceListResponse,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
PortalServiceDirectory,
|
||||
PortalServiceLaunchError,
|
||||
)
|
||||
|
||||
|
||||
READ_SCOPE = "portal:service:read"
|
||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||
_registry: object | None = None
|
||||
|
||||
|
||||
def configure_registry(registry: object | None) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
@router.get("/services", response_model=PortalServiceListResponse)
|
||||
def api_list_portal_services(
|
||||
q: str = Query(default="", max_length=200),
|
||||
include_unavailable: bool = True,
|
||||
effective_at: datetime | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceListResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
observed_at = effective_at or datetime.now(tz=UTC)
|
||||
if observed_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service effective_at must include a timezone.",
|
||||
)
|
||||
try:
|
||||
entries = PortalServiceDirectory(_registry).list_entries(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
effective_at=observed_at,
|
||||
query=q.strip(),
|
||||
limit=limit,
|
||||
include_unavailable=include_unavailable,
|
||||
)
|
||||
except InstitutionalContextError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return PortalServiceListResponse(
|
||||
services=[entry.to_dict() for entry in entries]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/services/{service_id}/launch",
|
||||
response_model=PortalServiceLaunchResponse,
|
||||
)
|
||||
def api_launch_portal_service(
|
||||
service_id: str,
|
||||
payload: PortalServiceLaunchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceLaunchResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
if payload.requested_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service launch requested_at must include a timezone.",
|
||||
)
|
||||
reference = InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="services",
|
||||
object_id=service_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
version=payload.service_version,
|
||||
valid_at=payload.requested_at,
|
||||
)
|
||||
try:
|
||||
result = PortalServiceDirectory(_registry).launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
requested_at=payload.requested_at,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
parameters=payload.parameters,
|
||||
)
|
||||
session.commit()
|
||||
except LookupError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PortalServiceLaunchError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (InstitutionalContextError, ValueError) as exc:
|
||||
session.rollback()
|
||||
status_code = 409 if "conflict" in str(exc).casefold() else 400
|
||||
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||
return PortalServiceLaunchResponse(**result.to_dict())
|
||||
|
||||
|
||||
__all__ = ["READ_SCOPE", "configure_registry", "router"]
|
||||
Reference in New Issue
Block a user