generated from coulomb/repo-seed
Implement ops-hub bootstrap compatibility
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user