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"}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
import core_hub.models # noqa: F401
|
||||
from core_hub import __version__
|
||||
from core_hub.api.v2 import router as api_v2_router
|
||||
from core_hub.config import get_settings
|
||||
from core_hub.db import Base, get_engine
|
||||
from core_hub.schemas import HealthResponse, ReadinessResponse
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
if settings.auto_create_tables:
|
||||
engine = get_engine()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="Core Hub API",
|
||||
version=__version__,
|
||||
description="Third-generation production interaction framework API.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||
|
||||
@@ -14,6 +14,9 @@ class Settings(BaseModel):
|
||||
)
|
||||
)
|
||||
api_token: str | None = Field(default_factory=lambda: os.getenv("CORE_HUB_API_TOKEN"))
|
||||
auto_create_tables: bool = Field(
|
||||
default_factory=lambda: os.getenv("CORE_HUB_AUTO_CREATE_TABLES", "0") == "1"
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_configured(self) -> bool:
|
||||
|
||||
13
src/core_hub/crypto.py
Normal file
13
src/core_hub/crypto.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def new_api_key(prefix: str = "ch") -> tuple[str, str, str]:
|
||||
secret = secrets.token_urlsafe(32)
|
||||
full_key = f"{prefix}_{secret}"
|
||||
key_prefix = full_key[:12]
|
||||
return full_key, key_prefix, hash_token(full_key)
|
||||
@@ -1,20 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def uuid_str() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Hub(Base):
|
||||
__tablename__ = "hubs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(240))
|
||||
domain: Mapped[str | None] = mapped_column(String(240))
|
||||
hub_kind: Mapped[str | None] = mapped_column(String(80))
|
||||
hub_family: Mapped[str | None] = mapped_column(String(80))
|
||||
vsm_function: Mapped[str | None] = mapped_column(String(80))
|
||||
vsm_system: Mapped[str | None] = mapped_column(String(80))
|
||||
status: Mapped[str] = mapped_column(String(40), default="active")
|
||||
description: Mapped[str | None] = mapped_column(Text())
|
||||
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
@@ -24,20 +34,71 @@ class Hub(Base):
|
||||
class HubCapabilityManifest(Base):
|
||||
__tablename__ = "hub_capability_manifests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
hub_slug: Mapped[str] = mapped_column(String(120), index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("hubs.id"), index=True)
|
||||
hub_slug: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||
manifest_version: Mapped[str] = mapped_column(String(40), default="0.1.0")
|
||||
status: Mapped[str] = mapped_column(String(40), default="draft")
|
||||
body_json: Mapped[str] = mapped_column(Text())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ApiConsumer(Base):
|
||||
__tablename__ = "api_consumers"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(240))
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
slug: Mapped[str | None] = mapped_column(String(120), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(240), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text())
|
||||
hub_capability_manifest_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer())
|
||||
quota_per_day: Mapped[int | None] = mapped_column(Integer())
|
||||
key_prefix: Mapped[str | None] = mapped_column(String(32))
|
||||
key_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(40), default="active")
|
||||
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
api_consumer_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("api_consumers.id"), index=True
|
||||
)
|
||||
key_prefix: Mapped[str] = mapped_column(String(32), index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
scopes: Mapped[str | None] = mapped_column(Text())
|
||||
status: Mapped[str] = mapped_column(String(40), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Widget(Base):
|
||||
__tablename__ = "widgets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
hub_id: Mapped[str] = mapped_column(String(36), ForeignKey("hubs.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(240))
|
||||
widget_type: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||
capability_ref: Mapped[str | None] = mapped_column(String(240), index=True)
|
||||
view_context: Mapped[str | None] = mapped_column(String(500))
|
||||
policy_scope: Mapped[str | None] = mapped_column(String(120))
|
||||
status: Mapped[str] = mapped_column(String(40), default="active")
|
||||
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class InteractionEvent(Base):
|
||||
__tablename__ = "interaction_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
widget_id: Mapped[str] = mapped_column(String(36), ForeignKey("widgets.id"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(160), index=True)
|
||||
view_context: Mapped[str | None] = mapped_column(String(500))
|
||||
metadata_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||
body_json: Mapped[str] = mapped_column(Text(), default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from .crypto import hash_token
|
||||
from .db import session_scope
|
||||
from .models import ApiKey
|
||||
|
||||
SettingsDependency = Annotated[Settings, Depends(get_settings)]
|
||||
SessionDependency = Annotated[AsyncSession, Depends(session_scope)]
|
||||
AuthorizationHeader = Annotated[str | None, Header()]
|
||||
|
||||
|
||||
def require_api_token(
|
||||
async def require_api_token(
|
||||
settings: SettingsDependency,
|
||||
session: SessionDependency,
|
||||
authorization: AuthorizationHeader = None,
|
||||
) -> str:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
@@ -19,10 +26,17 @@ def require_api_token(
|
||||
)
|
||||
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if not settings.api_token or token != settings.api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "unauthorized", "message": "Invalid bearer token"},
|
||||
)
|
||||
if settings.api_token and token == settings.api_token:
|
||||
return token
|
||||
|
||||
return token
|
||||
token_hash = hash_token(token)
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_hash == token_hash, ApiKey.status == "active")
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return token
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "unauthorized", "message": "Invalid bearer token"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user