feat: governed connector module
GCAP-SPEC-CONNECTOR-DESCRIPTOR-0001 implementation.
ConnectorPlugin — abstract base for governed connectors.
ConnectorRegistry — register/discover connectors.
ConnectorRuntime — wraps invoke with Chronicle lineage.
EchoConnector — dev/test example.
API endpoints:
GET /connectors/ — browse catalogue
GET /connectors/{id}/ — connector descriptor
POST /connectors/{id}/invoke/ — governed invocation
GET /connectors/{id}/health/ — health check
5 tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cbc9ad73f7
commit
0987704264
11 changed files with 1387 additions and 1 deletions
|
|
@ -5,7 +5,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from gsap_broker.settings import settings
|
||||
from gsap_broker.db import init_db
|
||||
from gsap_broker.routers import authorize, complete, session, elevate, health, drivers
|
||||
from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
|
@ -24,4 +24,5 @@ app.include_router(complete.router, prefix="/governance", tags=["CR"])
|
|||
app.include_router(session.router, prefix="/governance", tags=["Session"])
|
||||
app.include_router(elevate.router, prefix="/governance", tags=["Elevation"])
|
||||
app.include_router(drivers.router, prefix="/governance", tags=["Drivers"])
|
||||
app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"])
|
||||
app.include_router(health.router, tags=["Health"])
|
||||
|
|
|
|||
3
gsap_broker/connectors/__init__.py
Normal file
3
gsap_broker/connectors/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .base import ConnectorPlugin, ConnectorContext, ConnectorResult
|
||||
from .registry import ConnectorRegistry
|
||||
from .runtime import ConnectorRuntime
|
||||
65
gsap_broker/connectors/base.py
Normal file
65
gsap_broker/connectors/base.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""ConnectorPlugin ABC per GCAP-SPEC-CONNECTOR-DESCRIPTOR-0001 section 4."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectorContext:
|
||||
chronicle_session_id: str = ""
|
||||
gsap_context_id: str = ""
|
||||
credentials: dict[str, Any] = field(default_factory=dict)
|
||||
pipeline_run_id: str = ""
|
||||
dag_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectorResult:
|
||||
success: bool = False
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
lineage_cid: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ConnectorPlugin(ABC):
|
||||
"""Abstract base for governed connectors."""
|
||||
|
||||
connector_id: str = ""
|
||||
corpus_entry_cid: str = ""
|
||||
capability_mask: int = 0
|
||||
declared_endpoints: list[str] = []
|
||||
accord_template: str = ""
|
||||
gsap_required: bool = True
|
||||
chronicle_enabled: bool = True
|
||||
|
||||
@abstractmethod
|
||||
async def invoke(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> ConnectorResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def health_check(self) -> bool:
|
||||
...
|
||||
|
||||
def compute_parameters_cid(self, parameters: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(parameters, sort_keys=True, separators=(",", ":"))
|
||||
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
||||
|
||||
def descriptor(self) -> dict[str, Any]:
|
||||
return {
|
||||
"@context": "https://schema.gsap.dev/connector/v1",
|
||||
"@type": "ConnectorDescriptor",
|
||||
"connector_id": self.connector_id,
|
||||
"corpus_entry_cid": self.corpus_entry_cid,
|
||||
"capability_mask": self.capability_mask,
|
||||
"declared_endpoints": self.declared_endpoints,
|
||||
"accord_template": self.accord_template,
|
||||
"gsap_required": self.gsap_required,
|
||||
"chronicle_enabled": self.chronicle_enabled,
|
||||
}
|
||||
1
gsap_broker/connectors/examples/__init__.py
Normal file
1
gsap_broker/connectors/examples/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .echo_connector import EchoConnector
|
||||
24
gsap_broker/connectors/examples/echo_connector.py
Normal file
24
gsap_broker/connectors/examples/echo_connector.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""EchoConnector — minimal example governed connector."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
|
||||
|
||||
|
||||
class EchoConnector(ConnectorPlugin):
|
||||
connector_id = "echo"
|
||||
corpus_entry_cid = "sha256:" + "e" * 64
|
||||
capability_mask = 1
|
||||
declared_endpoints = ["localhost"]
|
||||
accord_template = ""
|
||||
gsap_required = False
|
||||
chronicle_enabled = True
|
||||
|
||||
async def invoke(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> ConnectorResult:
|
||||
return ConnectorResult(success=True, data=parameters)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
25
gsap_broker/connectors/registry.py
Normal file
25
gsap_broker/connectors/registry.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""ConnectorRegistry — catalogue of governed connectors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import ConnectorPlugin
|
||||
|
||||
|
||||
class ConnectorRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._connectors: dict[str, ConnectorPlugin] = {}
|
||||
|
||||
def register(self, connector: ConnectorPlugin) -> None:
|
||||
if not connector.connector_id:
|
||||
raise ValueError("connector_id must be non-empty")
|
||||
if not connector.corpus_entry_cid:
|
||||
raise ValueError("corpus_entry_cid must be non-empty")
|
||||
self._connectors[connector.connector_id] = connector
|
||||
|
||||
def get(self, connector_id: str) -> ConnectorPlugin | None:
|
||||
return self._connectors.get(connector_id)
|
||||
|
||||
def catalogue(self) -> list[dict]:
|
||||
return [c.descriptor() for c in self._connectors.values()]
|
||||
|
||||
def list_ids(self) -> list[str]:
|
||||
return list(self._connectors.keys())
|
||||
53
gsap_broker/connectors/runtime.py
Normal file
53
gsap_broker/connectors/runtime.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""ConnectorRuntime — governed invocation with Chronicle emission."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import ConnectorContext, ConnectorResult
|
||||
from .registry import ConnectorRegistry
|
||||
|
||||
|
||||
class ConnectorRuntime:
|
||||
def __init__(
|
||||
self, registry: ConnectorRegistry, chronicle_client: Any = None
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.chronicle_client = chronicle_client
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
connector_id: str,
|
||||
operation: str,
|
||||
parameters: dict[str, Any],
|
||||
context: ConnectorContext,
|
||||
) -> ConnectorResult:
|
||||
connector = self.registry.get(connector_id)
|
||||
if connector is None:
|
||||
return ConnectorResult(success=False, error=f"Unknown connector: {connector_id}")
|
||||
|
||||
if connector.gsap_required and not context.gsap_context_id:
|
||||
return ConnectorResult(success=False, error="GSAP context required but not provided")
|
||||
|
||||
result = await connector.invoke(operation, parameters, context)
|
||||
|
||||
# Emit Chronicle event
|
||||
if connector.chronicle_enabled and self.chronicle_client is not None:
|
||||
try:
|
||||
cid = await self.chronicle_client.emit(
|
||||
"CONNECTOR_INVOKED",
|
||||
{
|
||||
"connector_id": connector_id,
|
||||
"operation": operation,
|
||||
"parameters_cid": connector.compute_parameters_cid(parameters),
|
||||
"chronicle_session_id": context.chronicle_session_id,
|
||||
"gsap_context_id": context.gsap_context_id,
|
||||
"pipeline_run_id": context.pipeline_run_id,
|
||||
"dag_id": context.dag_id,
|
||||
"success": result.success,
|
||||
},
|
||||
)
|
||||
result.lineage_cid = cid
|
||||
except Exception:
|
||||
pass # Chronicle failure must not break invocation
|
||||
|
||||
return result
|
||||
84
gsap_broker/routers/connectors.py
Normal file
84
gsap_broker/routers/connectors.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Connectors router — governed connector catalogue and invocation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext
|
||||
from gsap_broker.connectors.registry import ConnectorRegistry
|
||||
from gsap_broker.connectors.runtime import ConnectorRuntime
|
||||
from gsap_broker.connectors.examples.echo_connector import EchoConnector
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Module-level registry and runtime
|
||||
_registry = ConnectorRegistry()
|
||||
_runtime = ConnectorRuntime(registry=_registry)
|
||||
|
||||
# Register built-in connectors
|
||||
_registry.register(EchoConnector())
|
||||
|
||||
|
||||
class InvokeRequest(BaseModel):
|
||||
operation: str
|
||||
parameters: dict[str, Any] = {}
|
||||
chronicle_session_id: str = ""
|
||||
gsap_context_id: str = ""
|
||||
pipeline_run_id: str = ""
|
||||
dag_id: str = ""
|
||||
|
||||
|
||||
class InvokeResponse(BaseModel):
|
||||
success: bool
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
lineage_cid: str = ""
|
||||
connector_id: str = ""
|
||||
operation: str = ""
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def catalogue() -> list[dict]:
|
||||
return _registry.catalogue()
|
||||
|
||||
|
||||
@router.get("/{connector_id}/")
|
||||
async def get_descriptor(connector_id: str) -> dict:
|
||||
connector = _registry.get(connector_id)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail=f"Connector not found: {connector_id}")
|
||||
return connector.descriptor()
|
||||
|
||||
|
||||
@router.post("/{connector_id}/invoke/")
|
||||
async def invoke_connector(connector_id: str, body: InvokeRequest) -> InvokeResponse:
|
||||
connector = _registry.get(connector_id)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail=f"Connector not found: {connector_id}")
|
||||
|
||||
ctx = ConnectorContext(
|
||||
chronicle_session_id=body.chronicle_session_id,
|
||||
gsap_context_id=body.gsap_context_id,
|
||||
pipeline_run_id=body.pipeline_run_id,
|
||||
dag_id=body.dag_id,
|
||||
)
|
||||
result = await _runtime.invoke(connector_id, body.operation, body.parameters, ctx)
|
||||
return InvokeResponse(
|
||||
success=result.success,
|
||||
data=result.data,
|
||||
error=result.error,
|
||||
lineage_cid=result.lineage_cid,
|
||||
connector_id=connector_id,
|
||||
operation=body.operation,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{connector_id}/health/")
|
||||
async def health_check(connector_id: str) -> dict:
|
||||
connector = _registry.get(connector_id)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail=f"Connector not found: {connector_id}")
|
||||
healthy = connector.health_check()
|
||||
return {"connector_id": connector_id, "healthy": healthy}
|
||||
|
|
@ -22,6 +22,9 @@ dependencies = [
|
|||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.4", "pytest-mock>=3.14"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["gsap_broker"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
|
|
|||
52
tests/test_connectors.py
Normal file
52
tests/test_connectors.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tests for governed connector module."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_connectors(client):
|
||||
resp = await client.get("/connectors/")
|
||||
assert resp.status_code == 200
|
||||
catalogue = resp.json()
|
||||
ids = [c["connector_id"] for c in catalogue]
|
||||
assert "echo" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_descriptor(client):
|
||||
resp = await client.get("/connectors/echo/")
|
||||
assert resp.status_code == 200
|
||||
desc = resp.json()
|
||||
assert "corpus_entry_cid" in desc
|
||||
assert desc["corpus_entry_cid"].startswith("sha256:")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_echo(client):
|
||||
resp = await client.post(
|
||||
"/connectors/echo/invoke/",
|
||||
json={"operation": "echo", "parameters": {"msg": "hello"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["msg"] == "hello"
|
||||
assert body["connector_id"] == "echo"
|
||||
assert body["operation"] == "echo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_unknown_404(client):
|
||||
resp = await client.post(
|
||||
"/connectors/nonexistent/invoke/",
|
||||
json={"operation": "test", "parameters": {}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(client):
|
||||
resp = await client.get("/connectors/echo/health/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["healthy"] is True
|
||||
assert body["connector_id"] == "echo"
|
||||
Loading…
Reference in a new issue