Implement ops-hub bootstrap compatibility

This commit is contained in:
2026-06-27 13:04:54 +02:00
parent 1e87adbcbb
commit c532ad8731
21 changed files with 1330 additions and 402 deletions

View File

@@ -1,32 +1,368 @@
from collections.abc import Callable
from typing import Annotated
import json
import re
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
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.schemas import CatalogItem, HubCapabilityManifest, HubResource
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,
PUBLIC_HUBS,
PUBLIC_MANIFESTS,
WIDGET_TYPES,
)
router = APIRouter(prefix="/api/v2", tags=["api-v2"])
TokenDependency = Annotated[str, Depends(require_api_token)]
SessionDependency = Annotated[AsyncSession, Depends(session_scope)]
@router.get("/hubs", response_model=list[HubResource])
def list_hubs() -> list[HubResource]:
return PUBLIC_HUBS
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "resource"
@router.get("/hub-capability-manifests", response_model=list[HubCapabilityManifest])
def list_hub_capability_manifests() -> list[HubCapabilityManifest]:
return PUBLIC_MANIFESTS
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])
@@ -50,13 +386,13 @@ def list_policy_scopes() -> list[CatalogItem]:
@router.get("/openapi.json", include_in_schema=False)
def openapi_json(request: Request) -> dict:
return request.app.openapi()
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 = request.app.openapi().get("info", {})
info = compatibility_openapi(request).get("info", {})
body = (
"openapi: 3.1.0\n"
"info:\n"
@@ -69,41 +405,3 @@ def openapi_yaml(request: Request) -> PlainTextResponse:
@router.get("/docs", include_in_schema=False)
def docs_redirect() -> RedirectResponse:
return RedirectResponse(url="/docs")
def protected_empty_collection(
name: str, method: str
) -> Callable[[TokenDependency], dict[str, list]]:
def endpoint(_: TokenDependency) -> dict[str, list]:
return {name: []}
endpoint.__name__ = f"{method.lower()}_{name}_endpoint"
return endpoint
def register_protected_collection(path: str, name: str, methods: tuple[str, ...]) -> None:
for method in methods:
method_lower = method.lower()
router.add_api_route(
path,
protected_empty_collection(name, method),
methods=[method],
tags=["api-v2-protected"],
operation_id=f"{method_lower}_{name}",
)
register_protected_collection("/api-consumers", "api_consumers", ("GET", "POST"))
register_protected_collection("/widgets", "widgets", ("GET", "POST"))
register_protected_collection("/interaction-events", "interaction_events", ("GET", "POST"))
register_protected_collection("/annotations", "annotations", ("GET", "POST"))
register_protected_collection("/requirement-candidates", "requirement_candidates", ("GET", "POST"))
register_protected_collection("/decision-records", "decision_records", ("GET", "POST"))
register_protected_collection("/deployment-records", "deployment_records", ("GET", "POST"))
register_protected_collection("/outcome-signals", "outcome_signals", ("GET", "POST"))
register_protected_collection("/hub-registry", "hub_registry", ("GET",))
@router.post("/token", tags=["api-v2-protected"], operation_id="post_token")
def issue_token(_: TokenDependency) -> dict[str, str]:
return {"access_token": "already-authenticated", "token_type": "bearer"}