import json import re from typing import Annotated, Any from fastapi import APIRouter, Depends, Query, Request, status from fastapi.responses import PlainTextResponse, RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core_hub.crypto import new_api_key from core_hub.db import session_scope from core_hub.models import ( ApiConsumer, ApiKey, Hub, HubCapabilityManifest, InteractionEvent, Widget, ) from core_hub.schemas import CatalogItem from core_hub.security import require_api_token from core_hub.seeds import ( ANNOTATION_CATEGORIES, EVENT_TYPES, POLICY_SCOPES, WIDGET_TYPES, ) router = APIRouter(prefix="/api/v2", tags=["api-v2"]) TokenDependency = Annotated[str, Depends(require_api_token)] SessionDependency = Annotated[AsyncSession, Depends(session_scope)] def slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or "resource" def encode_body(payload: dict[str, Any]) -> str: return json.dumps(payload, sort_keys=True) def decode_body(raw: str | None) -> dict[str, Any]: if not raw: return {} value = json.loads(raw) return value if isinstance(value, dict) else {} def page(data: list[dict[str, Any]]) -> dict[str, Any]: return {"data": data, "count": len(data)} def serialize_hub(row: Hub) -> dict[str, Any]: payload = decode_body(row.body_json) payload.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, } ) if row.description is not None: payload["description"] = row.description return {key: value for key, value in payload.items() if value is not None} def serialize_manifest(row: HubCapabilityManifest) -> dict[str, Any]: payload = decode_body(row.body_json) payload.update( { "id": row.id, "hubId": row.hub_id, "hubSlug": row.hub_slug, "manifestVersion": row.manifest_version, "status": row.status, } ) return {key: value for key, value in payload.items() if value is not None} def serialize_consumer(row: ApiConsumer) -> dict[str, Any]: payload = decode_body(row.body_json) payload.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: value for key, value in payload.items() if value is not None} def serialize_widget(row: Widget) -> dict[str, Any]: payload = decode_body(row.body_json) payload.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: value for key, value in payload.items() if value is not None} def serialize_event(row: InteractionEvent) -> dict[str, Any]: payload = decode_body(row.body_json) payload.update( { "id": row.id, "widgetId": row.widget_id, "eventType": row.event_type, "viewContext": row.view_context, "metadata": decode_body(row.metadata_json), } ) return {key: value for key, value in payload.items() if value is not None} def compatibility_openapi(request: Request) -> dict[str, Any]: 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("/hubs") async def list_hubs(_: TokenDependency, session: SessionDependency) -> dict[str, Any]: result = await session.execute(select(Hub).order_by(Hub.slug)) return page([serialize_hub(row) for row in result.scalars()]) @router.post("/hubs", status_code=status.HTTP_201_CREATED) async def create_hub( body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = Hub( 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_json=encode_body(body), ) session.add(row) await session.commit() await session.refresh(row) return serialize_hub(row) @router.get("/hub-capability-manifests") async def list_hub_capability_manifests( _: TokenDependency, session: SessionDependency, hub_id: Annotated[str | None, Query(alias="hubId")] = None, ) -> dict[str, Any]: statement = select(HubCapabilityManifest).order_by(HubCapabilityManifest.created_at) if hub_id: statement = statement.where(HubCapabilityManifest.hub_id == hub_id) result = await session.execute(statement) return page([serialize_manifest(row) for row in result.scalars()]) @router.post("/hub-capability-manifests", status_code=status.HTTP_201_CREATED) async def create_hub_capability_manifest( body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: hub_id = body.get("hubId") hub_slug = None if hub_id: hub = await session.get(Hub, hub_id) hub_slug = hub.slug if hub else None row = HubCapabilityManifest( hub_id=hub_id, hub_slug=hub_slug, manifest_version=body.get("manifestVersion", "0.1.0"), status=body.get("status", "draft"), body_json=encode_body(body), ) session.add(row) await session.commit() await session.refresh(row) return serialize_manifest(row) @router.patch("/hub-capability-manifests/{manifest_id}") async def update_hub_capability_manifest( manifest_id: str, body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = await session.get(HubCapabilityManifest, manifest_id) if row is None: return {"id": manifest_id, "status": "missing"} payload = decode_body(row.body_json) payload.update(body) row.manifest_version = payload.get("manifestVersion", row.manifest_version) row.body_json = encode_body(payload) await session.commit() await session.refresh(row) return serialize_manifest(row) @router.post("/hub-capability-manifests/{manifest_id}/activate") async def activate_hub_capability_manifest( manifest_id: str, _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = await session.get(HubCapabilityManifest, manifest_id) if row is None: return {"id": manifest_id, "status": "missing"} row.status = "active" await session.commit() await session.refresh(row) return serialize_manifest(row) @router.get("/api-consumers") async def list_api_consumers(_: TokenDependency, session: SessionDependency) -> dict[str, Any]: result = await session.execute(select(ApiConsumer).order_by(ApiConsumer.name)) return page([serialize_consumer(row) for row in result.scalars()]) @router.post("/api-consumers", status_code=status.HTTP_201_CREATED) async def create_api_consumer( body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = ApiConsumer( slug=body.get("slug") or slugify(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"), status=body.get("status", "active"), body_json=encode_body(body), ) session.add(row) await session.commit() await session.refresh(row) return serialize_consumer(row) @router.post("/api-consumers/{consumer_id}/api-keys", status_code=status.HTTP_201_CREATED) async def create_api_key( consumer_id: str, body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: full_key, key_prefix, key_hash = new_api_key() row = ApiKey( api_consumer_id=consumer_id, key_prefix=key_prefix, key_hash=key_hash, scopes=body.get("scopes"), ) session.add(row) consumer = await session.get(ApiConsumer, consumer_id) if consumer: consumer.key_prefix = key_prefix await session.commit() await session.refresh(row) return { "fullKey": full_key, "apiKey": { "id": row.id, "apiConsumerId": consumer_id, "keyPrefix": key_prefix, "scopes": row.scopes, "status": row.status, }, } @router.get("/widgets") async def list_widgets(_: TokenDependency, session: SessionDependency) -> dict[str, Any]: result = await session.execute(select(Widget).order_by(Widget.name)) return page([serialize_widget(row) for row in result.scalars()]) @router.post("/widgets", status_code=status.HTTP_201_CREATED) async def create_widget( body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = Widget( 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_json=encode_body(body), ) session.add(row) await session.commit() await session.refresh(row) return serialize_widget(row) @router.get("/interaction-events") async def list_interaction_events(_: TokenDependency, session: SessionDependency) -> dict[str, Any]: result = await session.execute(select(InteractionEvent).order_by(InteractionEvent.created_at)) return page([serialize_event(row) for row in result.scalars()]) @router.post("/interaction-events", status_code=status.HTTP_201_CREATED) async def create_interaction_event( body: dict[str, Any], _: TokenDependency, session: SessionDependency ) -> dict[str, Any]: row = InteractionEvent( widget_id=body["widgetId"], event_type=body["eventType"], view_context=body.get("viewContext"), metadata_json=encode_body(body.get("metadata", {})), body_json=encode_body(body), ) session.add(row) await session.commit() await session.refresh(row) return serialize_event(row) @router.get("/hub-registry") async def hub_registry(_: TokenDependency, session: SessionDependency) -> dict[str, Any]: hubs = await list_hubs(_, session) manifests = await list_hub_capability_manifests(_, session) return {"data": {"hubs": hubs["data"], "hubCapabilityManifests": manifests["data"]}} @router.get("/annotations") @router.post("/annotations", status_code=status.HTTP_201_CREATED) @router.get("/requirement-candidates") @router.post("/requirement-candidates", status_code=status.HTTP_201_CREATED) @router.get("/decision-records") @router.post("/decision-records", status_code=status.HTTP_201_CREATED) @router.get("/deployment-records") @router.post("/deployment-records", status_code=status.HTTP_201_CREATED) @router.get("/outcome-signals") @router.post("/outcome-signals", status_code=status.HTTP_201_CREATED) async def deferred_collection(_: TokenDependency) -> dict[str, list]: return {"data": []} @router.post("/token", tags=["api-v2-protected"], operation_id="post_token") async def issue_token(_: TokenDependency) -> dict[str, str]: return {"access_token": "already-authenticated", "token_type": "bearer"} @router.get("/widget-types", response_model=list[CatalogItem]) def list_widget_types() -> list[CatalogItem]: return WIDGET_TYPES @router.get("/event-types", response_model=list[CatalogItem]) def list_event_types() -> list[CatalogItem]: return EVENT_TYPES @router.get("/annotation-categories", response_model=list[CatalogItem]) def list_annotation_categories() -> list[CatalogItem]: return ANNOTATION_CATEGORIES @router.get("/policy-scopes", response_model=list[CatalogItem]) def list_policy_scopes() -> list[CatalogItem]: return POLICY_SCOPES @router.get("/openapi.json", include_in_schema=False) def openapi_json(request: Request) -> dict[str, Any]: return compatibility_openapi(request) @router.get("/openapi.yaml", include_in_schema=False) def openapi_yaml(request: Request) -> PlainTextResponse: info = compatibility_openapi(request).get("info", {}) body = ( "openapi: 3.1.0\n" "info:\n" f" title: {info.get('title', 'Core Hub API')}\n" f" version: {info.get('version', '0.1.0')}\n" ) return PlainTextResponse(body, media_type="application/yaml") @router.get("/docs", include_in_schema=False) def docs_redirect() -> RedirectResponse: return RedirectResponse(url="/docs")