605 lines
22 KiB
Python
605 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated, Any
|
|
from uuid import uuid4
|
|
|
|
import sqlalchemy as sa
|
|
from fastapi import APIRouter, Header, HTTPException, Request, status
|
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
|
|
|
from hub_core.runtime.compat_catalogs import (
|
|
ANNOTATION_CATEGORIES,
|
|
EVENT_TYPES,
|
|
POLICY_SCOPES,
|
|
WIDGET_TYPES,
|
|
)
|
|
from hub_core.runtime.models import EventCommand
|
|
from hub_core.runtime.postgres_store import PostgresPortStore
|
|
from hub_core.runtime.tables import (
|
|
compat_api_consumers,
|
|
compat_api_keys,
|
|
compat_hubs,
|
|
compat_manifests,
|
|
compat_widgets,
|
|
runtime_interaction_events,
|
|
)
|
|
from hub_core.utils.slugs import slugify_or_default
|
|
|
|
AuthorizationHeader = Annotated[str | None, Header()]
|
|
|
|
|
|
class SQLCompatibilityStore:
|
|
def __init__(self, ports: PostgresPortStore) -> None:
|
|
self.ports = ports
|
|
self.sessions = ports.sessions
|
|
|
|
async def validate_token(self, token: str) -> bool:
|
|
token_hash = _hash_token(token)
|
|
async with self.sessions() as session:
|
|
result = await session.execute(
|
|
sa.select(compat_api_keys.c.id).where(
|
|
compat_api_keys.c.key_hash == token_hash,
|
|
compat_api_keys.c.status == "active",
|
|
)
|
|
)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
async def list_hubs(self) -> list[dict[str, Any]]:
|
|
async with self.sessions() as session:
|
|
rows = (
|
|
await session.execute(sa.select(compat_hubs).order_by(compat_hubs.c.slug))
|
|
).mappings()
|
|
return [_hub(row) for row in rows]
|
|
|
|
async def create_hub(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
now = _now()
|
|
row = {
|
|
"id": str(uuid4()),
|
|
"slug": body["slug"],
|
|
"name": body["name"],
|
|
"domain": body.get("domain"),
|
|
"hub_kind": body.get("hubKind"),
|
|
"hub_family": body.get("hubFamily"),
|
|
"vsm_function": body.get("vsmFunction"),
|
|
"vsm_system": body.get("vsmSystem"),
|
|
"status": body.get("status", "active"),
|
|
"description": body.get("description"),
|
|
"body": body,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
async with self.sessions.begin() as session:
|
|
await session.execute(compat_hubs.insert().values(**row))
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.hub.created",
|
|
subject_type="hub",
|
|
subject_id=row["id"],
|
|
correlation_id=None,
|
|
value=body,
|
|
)
|
|
return _hub(row)
|
|
|
|
async def list_manifests(self, hub_id: str | None = None) -> list[dict[str, Any]]:
|
|
statement = sa.select(compat_manifests).order_by(compat_manifests.c.created_at)
|
|
if hub_id:
|
|
statement = statement.where(compat_manifests.c.hub_id == hub_id)
|
|
async with self.sessions() as session:
|
|
rows = (await session.execute(statement)).mappings()
|
|
return [_manifest(row) for row in rows]
|
|
|
|
async def create_manifest(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
now = _now()
|
|
hub_id = body.get("hubId")
|
|
hub_slug = None
|
|
if hub_id:
|
|
async with self.sessions() as session:
|
|
hub_slug = (
|
|
await session.execute(
|
|
sa.select(compat_hubs.c.slug).where(compat_hubs.c.id == hub_id)
|
|
)
|
|
).scalar_one_or_none()
|
|
row = {
|
|
"id": str(uuid4()),
|
|
"hub_id": hub_id,
|
|
"hub_slug": hub_slug,
|
|
"manifest_version": body.get("manifestVersion", "0.1.0"),
|
|
"status": body.get("status", "draft"),
|
|
"body": body,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
async with self.sessions.begin() as session:
|
|
await session.execute(compat_manifests.insert().values(**row))
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.manifest.created",
|
|
subject_type="manifest",
|
|
subject_id=row["id"],
|
|
correlation_id=None,
|
|
value=body,
|
|
)
|
|
return _manifest(row)
|
|
|
|
async def update_manifest(
|
|
self, manifest_id: str, body: dict[str, Any], *, activate: bool = False
|
|
) -> dict[str, Any]:
|
|
async with self.sessions.begin() as session:
|
|
current = (
|
|
await session.execute(
|
|
sa.select(compat_manifests).where(compat_manifests.c.id == manifest_id)
|
|
)
|
|
).mappings().one_or_none()
|
|
if current is None:
|
|
return {"id": manifest_id, "status": "missing"}
|
|
payload = dict(current["body"] or {})
|
|
payload.update(body)
|
|
values = {
|
|
"body": payload,
|
|
"manifest_version": payload.get(
|
|
"manifestVersion", current["manifest_version"]
|
|
),
|
|
"status": "active" if activate else current["status"],
|
|
"updated_at": _now(),
|
|
}
|
|
await session.execute(
|
|
compat_manifests.update()
|
|
.where(compat_manifests.c.id == manifest_id)
|
|
.values(**values)
|
|
)
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.manifest.activated" if activate else "compat.manifest.updated",
|
|
subject_type="manifest",
|
|
subject_id=manifest_id,
|
|
correlation_id=None,
|
|
value=payload,
|
|
)
|
|
return _manifest({**dict(current), **values})
|
|
|
|
async def list_consumers(self) -> list[dict[str, Any]]:
|
|
async with self.sessions() as session:
|
|
rows = (
|
|
await session.execute(
|
|
sa.select(compat_api_consumers).order_by(compat_api_consumers.c.name)
|
|
)
|
|
).mappings()
|
|
return [_consumer(row) for row in rows]
|
|
|
|
async def create_consumer(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
row = {
|
|
"id": str(uuid4()),
|
|
"slug": body.get("slug") or slugify_or_default(body["name"]),
|
|
"name": body["name"],
|
|
"description": body.get("description"),
|
|
"hub_capability_manifest_id": body.get("hubCapabilityManifestId"),
|
|
"rate_limit_per_minute": body.get("rateLimitPerMinute"),
|
|
"quota_per_day": body.get("quotaPerDay"),
|
|
"key_prefix": None,
|
|
"status": body.get("status", "active"),
|
|
"body": body,
|
|
"created_at": _now(),
|
|
}
|
|
async with self.sessions.begin() as session:
|
|
await session.execute(compat_api_consumers.insert().values(**row))
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.consumer.created",
|
|
subject_type="api_consumer",
|
|
subject_id=row["id"],
|
|
correlation_id=None,
|
|
value=body,
|
|
)
|
|
return _consumer(row)
|
|
|
|
async def create_key(self, consumer_id: str, scopes: str | None) -> dict[str, Any]:
|
|
full_key = f"ch_{secrets.token_urlsafe(32)}"
|
|
prefix = full_key[:12]
|
|
row = {
|
|
"id": str(uuid4()),
|
|
"api_consumer_id": consumer_id,
|
|
"key_prefix": prefix,
|
|
"key_hash": _hash_token(full_key),
|
|
"scopes": scopes,
|
|
"status": "active",
|
|
"created_at": _now(),
|
|
}
|
|
async with self.sessions.begin() as session:
|
|
await session.execute(compat_api_keys.insert().values(**row))
|
|
await session.execute(
|
|
compat_api_consumers.update()
|
|
.where(compat_api_consumers.c.id == consumer_id)
|
|
.values(key_prefix=prefix)
|
|
)
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.api_key.created",
|
|
subject_type="api_key",
|
|
subject_id=row["id"],
|
|
correlation_id=None,
|
|
value={"apiConsumerId": consumer_id, "keyPrefix": prefix, "scopes": scopes},
|
|
)
|
|
return {
|
|
"fullKey": full_key,
|
|
"apiKey": {
|
|
"id": row["id"],
|
|
"apiConsumerId": consumer_id,
|
|
"keyPrefix": prefix,
|
|
"scopes": scopes,
|
|
"status": "active",
|
|
},
|
|
}
|
|
|
|
async def list_widgets(self) -> list[dict[str, Any]]:
|
|
async with self.sessions() as session:
|
|
rows = (
|
|
await session.execute(sa.select(compat_widgets).order_by(compat_widgets.c.name))
|
|
).mappings()
|
|
return [_widget(row) for row in rows]
|
|
|
|
async def create_widget(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
row = {
|
|
"id": str(uuid4()),
|
|
"hub_id": body["hubId"],
|
|
"name": body["name"],
|
|
"widget_type": body.get("widgetType"),
|
|
"capability_ref": body.get("capabilityRef"),
|
|
"view_context": body.get("viewContext"),
|
|
"policy_scope": body.get("policyScope"),
|
|
"status": body.get("status", "active"),
|
|
"body": body,
|
|
"created_at": _now(),
|
|
}
|
|
async with self.sessions.begin() as session:
|
|
await session.execute(compat_widgets.insert().values(**row))
|
|
await self.ports._audit(
|
|
session,
|
|
action="compat.widget.created",
|
|
subject_type="widget",
|
|
subject_id=row["id"],
|
|
correlation_id=None,
|
|
value=body,
|
|
)
|
|
return _widget(row)
|
|
|
|
async def list_interactions(self) -> list[dict[str, Any]]:
|
|
async with self.sessions() as session:
|
|
rows = (
|
|
await session.execute(
|
|
sa.select(runtime_interaction_events).order_by(
|
|
runtime_interaction_events.c.recorded_at,
|
|
runtime_interaction_events.c.id,
|
|
)
|
|
)
|
|
).mappings()
|
|
values: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
legacy = dict((row["payload"] or {}).get("legacy") or {})
|
|
if legacy:
|
|
legacy["id"] = row["id"]
|
|
values.append(legacy)
|
|
return values
|
|
|
|
async def create_interaction(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
correlation_id = uuid4()
|
|
command = EventCommand(
|
|
schema_version="0.1.0",
|
|
correlation_id=correlation_id,
|
|
event_type=body["eventType"],
|
|
occurred_at=_now(),
|
|
subject_refs={"widget": body["widgetId"]},
|
|
payload={"legacy": body},
|
|
)
|
|
accepted = await self.ports.append_interaction(command)
|
|
return {"id": accepted.id, **body}
|
|
|
|
|
|
def create_compatibility_router() -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.get("/api/v2/widget-types")
|
|
async def widget_types(request: Request) -> list[dict[str, str]]:
|
|
_enabled(request, "system")
|
|
return WIDGET_TYPES
|
|
|
|
@router.get("/api/v2/event-types")
|
|
async def event_types(request: Request) -> list[dict[str, str]]:
|
|
_enabled(request, "system")
|
|
return EVENT_TYPES
|
|
|
|
@router.get("/api/v2/annotation-categories")
|
|
async def annotation_categories(request: Request) -> list[dict[str, str]]:
|
|
_enabled(request, "system")
|
|
return ANNOTATION_CATEGORIES
|
|
|
|
@router.get("/api/v2/policy-scopes")
|
|
async def policy_scopes(request: Request) -> list[dict[str, str]]:
|
|
_enabled(request, "system")
|
|
return POLICY_SCOPES
|
|
|
|
@router.get("/api/v2/hubs")
|
|
async def list_hubs(request: Request, authorization: AuthorizationHeader = None) -> dict:
|
|
await _protected(request, "registry", authorization)
|
|
return _page(await _store(request).list_hubs())
|
|
|
|
@router.post("/api/v2/hubs", status_code=status.HTTP_201_CREATED)
|
|
async def create_hub(
|
|
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization, write=True)
|
|
return await _store(request).create_hub(body)
|
|
|
|
@router.get("/api/v2/hub-capability-manifests")
|
|
async def list_manifests(
|
|
request: Request,
|
|
authorization: AuthorizationHeader = None,
|
|
hubId: str | None = None,
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization)
|
|
return _page(await _store(request).list_manifests(hubId))
|
|
|
|
@router.post("/api/v2/hub-capability-manifests", status_code=status.HTTP_201_CREATED)
|
|
async def create_manifest(
|
|
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization, write=True)
|
|
return await _store(request).create_manifest(body)
|
|
|
|
@router.patch("/api/v2/hub-capability-manifests/{manifest_id}")
|
|
async def patch_manifest(
|
|
manifest_id: str,
|
|
body: dict[str, Any],
|
|
request: Request,
|
|
authorization: AuthorizationHeader = None,
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization, write=True)
|
|
return await _store(request).update_manifest(manifest_id, body)
|
|
|
|
@router.post("/api/v2/hub-capability-manifests/{manifest_id}/activate")
|
|
async def activate_manifest(
|
|
manifest_id: str, request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization, write=True)
|
|
return await _store(request).update_manifest(manifest_id, {}, activate=True)
|
|
|
|
@router.get("/api/v2/hub-registry")
|
|
async def hub_registry(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "registry", authorization)
|
|
store = _store(request)
|
|
return {
|
|
"data": {
|
|
"hubs": await store.list_hubs(),
|
|
"hubCapabilityManifests": await store.list_manifests(),
|
|
}
|
|
}
|
|
|
|
@router.get("/api/v2/api-consumers")
|
|
async def list_consumers(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "credentials", authorization)
|
|
return _page(await _store(request).list_consumers())
|
|
|
|
@router.post("/api/v2/api-consumers", status_code=status.HTTP_201_CREATED)
|
|
async def create_consumer(
|
|
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "credentials", authorization, write=True)
|
|
return await _store(request).create_consumer(body)
|
|
|
|
@router.post(
|
|
"/api/v2/api-consumers/{consumer_id}/api-keys",
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_key(
|
|
consumer_id: str,
|
|
body: dict[str, Any],
|
|
request: Request,
|
|
authorization: AuthorizationHeader = None,
|
|
) -> dict:
|
|
await _protected(request, "credentials", authorization, write=True)
|
|
return await _store(request).create_key(consumer_id, body.get("scopes"))
|
|
|
|
@router.post("/api/v2/token")
|
|
async def token(request: Request, authorization: AuthorizationHeader = None) -> dict:
|
|
await _protected(request, "credentials", authorization)
|
|
return {"access_token": "already-authenticated", "token_type": "bearer"}
|
|
|
|
@router.get("/api/v2/widgets")
|
|
async def list_widgets(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "interaction", authorization)
|
|
return _page(await _store(request).list_widgets())
|
|
|
|
@router.post("/api/v2/widgets", status_code=status.HTTP_201_CREATED)
|
|
async def create_widget(
|
|
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "interaction", authorization, write=True)
|
|
return await _store(request).create_widget(body)
|
|
|
|
@router.get("/api/v2/interaction-events")
|
|
async def list_interactions(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "interaction", authorization)
|
|
return _page(await _store(request).list_interactions())
|
|
|
|
@router.post("/api/v2/interaction-events", status_code=status.HTTP_201_CREATED)
|
|
async def create_interaction(
|
|
body: dict[str, Any], request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "interaction", authorization, write=True)
|
|
return await _store(request).create_interaction(body)
|
|
|
|
for index, path in enumerate((
|
|
"annotations",
|
|
"requirement-candidates",
|
|
"decision-records",
|
|
"deployment-records",
|
|
"outcome-signals",
|
|
)):
|
|
async def empty_collection(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> dict:
|
|
await _protected(request, "deferred", authorization)
|
|
return {"data": []}
|
|
|
|
stem = path.replace("-", "_")
|
|
router.add_api_route(
|
|
f"/api/v2/{path}",
|
|
empty_collection,
|
|
methods=["GET"],
|
|
operation_id=f"compat_list_{stem}_{index}",
|
|
)
|
|
router.add_api_route(
|
|
f"/api/v2/{path}",
|
|
empty_collection,
|
|
methods=["POST"],
|
|
status_code=201,
|
|
operation_id=f"compat_create_{stem}_{index}",
|
|
)
|
|
|
|
@router.get("/api/v2/openapi.json", include_in_schema=False)
|
|
async def openapi_json(request: Request) -> dict[str, Any]:
|
|
_enabled(request, "system")
|
|
document = request.app.openapi()
|
|
paths = document.setdefault("paths", {})
|
|
for path, operations in list(paths.items()):
|
|
if path.startswith("/api/v2/"):
|
|
paths.setdefault(path.removeprefix("/api/v2"), operations)
|
|
return document
|
|
|
|
@router.get("/api/v2/openapi.yaml", include_in_schema=False)
|
|
async def openapi_yaml(request: Request) -> PlainTextResponse:
|
|
document = await openapi_json(request)
|
|
info = document.get("info", {})
|
|
return PlainTextResponse(
|
|
"openapi: 3.1.0\ninfo:\n"
|
|
f" title: {info.get('title', 'Hub Core Runtime')}\n"
|
|
f" version: {info.get('version', '0.2.0')}\n",
|
|
media_type="application/yaml",
|
|
)
|
|
|
|
@router.get("/api/v2/docs", include_in_schema=False)
|
|
async def docs(request: Request) -> RedirectResponse:
|
|
_enabled(request, "system")
|
|
return RedirectResponse(url="/docs")
|
|
|
|
@router.get("/console", response_class=HTMLResponse)
|
|
async def console(
|
|
request: Request, authorization: AuthorizationHeader = None
|
|
) -> HTMLResponse:
|
|
await _protected(request, "operator", authorization)
|
|
return HTMLResponse(
|
|
"<!doctype html><title>Hub Core Operator Console</title>"
|
|
"<h1>Hub Core Operator Console</h1>"
|
|
"<p>Compatibility runtime candidate; projections are authoritative.</p>"
|
|
)
|
|
|
|
return router
|
|
|
|
|
|
def _enabled(request: Request, group: str) -> None:
|
|
if group not in request.app.state.settings.v2_groups:
|
|
raise HTTPException(status_code=404, detail="compatibility group is disabled")
|
|
|
|
|
|
async def _protected(
|
|
request: Request,
|
|
group: str,
|
|
authorization: str | None,
|
|
*,
|
|
write: bool = False,
|
|
) -> None:
|
|
_enabled(request, group)
|
|
if write and group not in request.app.state.settings.v2_write_groups:
|
|
raise HTTPException(status_code=503, detail="compatibility group is read-only")
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise _unauthorized("Missing bearer token")
|
|
token = authorization.removeprefix("Bearer ").strip()
|
|
configured = request.app.state.settings.api_token
|
|
if configured and hmac.compare_digest(token, configured):
|
|
return
|
|
try:
|
|
valid = await _store(request).validate_token(token)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=503, detail="authorization dependency unavailable") from exc
|
|
if not valid:
|
|
raise _unauthorized("Invalid bearer token")
|
|
|
|
|
|
def _store(request: Request) -> SQLCompatibilityStore:
|
|
store = request.app.state.compat_store
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="compatibility store unavailable")
|
|
return store
|
|
|
|
|
|
def _unauthorized(message: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={"code": "unauthorized", "message": message},
|
|
)
|
|
|
|
|
|
def _page(values: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {"data": values, "count": len(values)}
|
|
|
|
|
|
def _hub(row: Any) -> dict[str, Any]:
|
|
value = dict(row["body"] or {})
|
|
value.update(
|
|
id=row["id"], slug=row["slug"], name=row["name"], domain=row["domain"],
|
|
hubKind=row["hub_kind"], hubFamily=row["hub_family"],
|
|
vsmFunction=row["vsm_function"], vsmSystem=row["vsm_system"],
|
|
status=row["status"], description=row["description"],
|
|
)
|
|
return {key: item for key, item in value.items() if item is not None}
|
|
|
|
|
|
def _manifest(row: Any) -> dict[str, Any]:
|
|
value = dict(row["body"] or {})
|
|
value.update(
|
|
id=row["id"], hubId=row["hub_id"], hubSlug=row["hub_slug"],
|
|
manifestVersion=row["manifest_version"], status=row["status"],
|
|
)
|
|
return {key: item for key, item in value.items() if item is not None}
|
|
|
|
|
|
def _consumer(row: Any) -> dict[str, Any]:
|
|
value = dict(row["body"] or {})
|
|
value.update(
|
|
id=row["id"], slug=row["slug"], name=row["name"],
|
|
description=row["description"],
|
|
hubCapabilityManifestId=row["hub_capability_manifest_id"],
|
|
rateLimitPerMinute=row["rate_limit_per_minute"], quotaPerDay=row["quota_per_day"],
|
|
keyPrefix=row["key_prefix"], status=row["status"],
|
|
)
|
|
return {key: item for key, item in value.items() if item is not None}
|
|
|
|
|
|
def _widget(row: Any) -> dict[str, Any]:
|
|
value = dict(row["body"] or {})
|
|
value.update(
|
|
id=row["id"], hubId=row["hub_id"], name=row["name"],
|
|
widgetType=row["widget_type"], capabilityRef=row["capability_ref"],
|
|
viewContext=row["view_context"], policyScope=row["policy_scope"],
|
|
status=row["status"],
|
|
)
|
|
return {key: item for key, item in value.items() if item is not None}
|
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|