generated from coulomb/repo-seed
Start Core Hub FastAPI replacement foundation
This commit is contained in:
5
src/core_hub/__init__.py
Normal file
5
src/core_hub/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Core Hub runtime package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
src/core_hub/api/__init__.py
Normal file
1
src/core_hub/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""HTTP API routers."""
|
||||
109
src/core_hub/api/v2.py
Normal file
109
src/core_hub/api/v2.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||
|
||||
from core_hub.schemas import CatalogItem, HubCapabilityManifest, HubResource
|
||||
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)]
|
||||
|
||||
|
||||
@router.get("/hubs", response_model=list[HubResource])
|
||||
def list_hubs() -> list[HubResource]:
|
||||
return PUBLIC_HUBS
|
||||
|
||||
|
||||
@router.get("/hub-capability-manifests", response_model=list[HubCapabilityManifest])
|
||||
def list_hub_capability_manifests() -> list[HubCapabilityManifest]:
|
||||
return PUBLIC_MANIFESTS
|
||||
|
||||
|
||||
@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:
|
||||
return request.app.openapi()
|
||||
|
||||
|
||||
@router.get("/openapi.yaml", include_in_schema=False)
|
||||
def openapi_yaml(request: Request) -> PlainTextResponse:
|
||||
info = request.app.openapi().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")
|
||||
|
||||
|
||||
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"}
|
||||
35
src/core_hub/app.py
Normal file
35
src/core_hub/app.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
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.schemas import HealthResponse, ReadinessResponse
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="Core Hub API",
|
||||
version=__version__,
|
||||
description="Third-generation production interaction framework API.",
|
||||
)
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||
def healthz() -> HealthResponse:
|
||||
return HealthResponse(service="core-hub", status="ok", version=__version__)
|
||||
|
||||
@app.get("/readyz", response_model=ReadinessResponse, tags=["system"])
|
||||
def readyz() -> ReadinessResponse:
|
||||
checks = {
|
||||
"configuration": "ok",
|
||||
"database_url": "configured" if settings.database_url else "missing",
|
||||
"api_auth": "configured" if settings.auth_configured else "not_configured",
|
||||
}
|
||||
status = "ok" if checks["database_url"] == "configured" else "degraded"
|
||||
return ReadinessResponse(service="core-hub", status=status, checks=checks)
|
||||
|
||||
app.include_router(api_v2_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
25
src/core_hub/config.py
Normal file
25
src/core_hub/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
app_name: str = "Core Hub"
|
||||
environment: str = Field(default_factory=lambda: os.getenv("CORE_HUB_ENV", "development"))
|
||||
database_url: str = Field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"CORE_HUB_DATABASE_URL",
|
||||
"postgresql+asyncpg://core_hub:core_hub@localhost:5432/core_hub",
|
||||
)
|
||||
)
|
||||
api_token: str | None = Field(default_factory=lambda: os.getenv("CORE_HUB_API_TOKEN"))
|
||||
|
||||
@property
|
||||
def auth_configured(self) -> bool:
|
||||
return bool(self.api_token)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
36
src/core_hub/db.py
Normal file
36
src/core_hub/db.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def make_engine(database_url: str) -> AsyncEngine:
|
||||
return create_async_engine(database_url, pool_pre_ping=True)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_engine() -> AsyncEngine:
|
||||
return make_engine(get_settings().database_url)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||
|
||||
|
||||
async def session_scope() -> AsyncIterator[AsyncSession]:
|
||||
async_session = get_sessionmaker()
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
43
src/core_hub/models.py
Normal file
43
src/core_hub/models.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
class Hub(Base):
|
||||
__tablename__ = "hubs"
|
||||
|
||||
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))
|
||||
status: Mapped[str] = mapped_column(String(40), default="active")
|
||||
description: Mapped[str | None] = 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 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)
|
||||
manifest_version: Mapped[str] = mapped_column(String(40), default="0.1.0")
|
||||
body_json: Mapped[str] = mapped_column(Text())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=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))
|
||||
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")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
45
src/core_hub/schemas.py
Normal file
45
src/core_hub/schemas.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Link(BaseModel):
|
||||
rel: str
|
||||
href: str
|
||||
|
||||
|
||||
class CatalogItem(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class HubResource(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
description: str | None = None
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
links: list[Link] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HubCapabilityManifest(BaseModel):
|
||||
hub_slug: str
|
||||
manifest_version: str = "0.1.0"
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
endpoints: list[Link] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
service: str
|
||||
status: str
|
||||
version: str
|
||||
|
||||
|
||||
class ReadinessResponse(BaseModel):
|
||||
service: str
|
||||
status: str
|
||||
checks: dict[str, str]
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
28
src/core_hub/security.py
Normal file
28
src/core_hub/security.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from .config import Settings, get_settings
|
||||
|
||||
SettingsDependency = Annotated[Settings, Depends(get_settings)]
|
||||
AuthorizationHeader = Annotated[str | None, Header()]
|
||||
|
||||
|
||||
def require_api_token(
|
||||
settings: SettingsDependency,
|
||||
authorization: AuthorizationHeader = None,
|
||||
) -> str:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "unauthorized", "message": "Missing bearer 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"},
|
||||
)
|
||||
|
||||
return token
|
||||
56
src/core_hub/seeds.py
Normal file
56
src/core_hub/seeds.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from .schemas import CatalogItem, HubCapabilityManifest, HubResource, Link
|
||||
|
||||
PUBLIC_HUBS = [
|
||||
HubResource(
|
||||
slug="core-hub",
|
||||
name="Core Hub",
|
||||
description="Third-generation production interaction framework.",
|
||||
capabilities=[
|
||||
"hub-registry",
|
||||
"interaction-events",
|
||||
"workplan-coordination",
|
||||
"operator-console",
|
||||
],
|
||||
links=[Link(rel="self", href="/api/v2/hubs/core-hub")],
|
||||
)
|
||||
]
|
||||
|
||||
PUBLIC_MANIFESTS = [
|
||||
HubCapabilityManifest(
|
||||
hub_slug="core-hub",
|
||||
capabilities=[
|
||||
"capability.infotech.core-hub",
|
||||
"api.v2.compatibility",
|
||||
"contract.ir",
|
||||
],
|
||||
endpoints=[
|
||||
Link(rel="hubs", href="/api/v2/hubs"),
|
||||
Link(rel="events", href="/api/v2/interaction-events"),
|
||||
Link(rel="widgets", href="/api/v2/widgets"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
WIDGET_TYPES = [
|
||||
CatalogItem(slug="status-summary", name="Status Summary"),
|
||||
CatalogItem(slug="workplan-board", name="Workplan Board"),
|
||||
CatalogItem(slug="event-stream", name="Event Stream"),
|
||||
]
|
||||
|
||||
EVENT_TYPES = [
|
||||
CatalogItem(slug="interaction.event", name="Interaction Event"),
|
||||
CatalogItem(slug="workplan.progress", name="Workplan Progress"),
|
||||
CatalogItem(slug="evidence.recorded", name="Evidence Recorded"),
|
||||
]
|
||||
|
||||
ANNOTATION_CATEGORIES = [
|
||||
CatalogItem(slug="operator-note", name="Operator Note"),
|
||||
CatalogItem(slug="compatibility-delta", name="Compatibility Delta"),
|
||||
CatalogItem(slug="migration-evidence", name="Migration Evidence"),
|
||||
]
|
||||
|
||||
POLICY_SCOPES = [
|
||||
CatalogItem(slug="public-read", name="Public Read"),
|
||||
CatalogItem(slug="hub-write", name="Hub Write"),
|
||||
CatalogItem(slug="operator-admin", name="Operator Admin"),
|
||||
]
|
||||
Reference in New Issue
Block a user