Skip to content

Commit feff4f0

Browse files
feat: implement integrations framework and webhook notifier reference implementation
1 parent b1120b2 commit feff4f0

9 files changed

Lines changed: 545 additions & 11 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Integrations management and dispatch API routes."""
2+
3+
from typing import Any
4+
5+
from fastapi import APIRouter, Depends, HTTPException, status
6+
from pydantic import BaseModel
7+
from sqlalchemy import select
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from docsgraph_server.db.models import DbIntegrationConfig
11+
from docsgraph_server.db.session import get_session
12+
from docsgraph_server.integrations.engine import IntegrationManager
13+
14+
router = APIRouter(prefix="/integrations", tags=["integrations"])
15+
16+
17+
class IntegrationConfigUpdate(BaseModel):
18+
"""Pydantic model for updating an integration configuration."""
19+
20+
enabled: bool
21+
config_json: dict[str, Any]
22+
23+
24+
class IntegrationStatusResponse(BaseModel):
25+
"""Pydantic model for integration status responses."""
26+
27+
id: str
28+
name: str
29+
enabled: bool
30+
config_json: dict[str, Any]
31+
32+
33+
class DispatchTestRequest(BaseModel):
34+
"""Pydantic model for triggering a test event dispatch."""
35+
36+
event_type: str
37+
payload: dict[str, Any]
38+
39+
40+
@router.get("", response_model=list[IntegrationStatusResponse])
41+
async def list_integrations(
42+
db: AsyncSession = Depends(get_session), # noqa: B008
43+
) -> list[dict[str, Any]]:
44+
"""List all registered integrations and their current configurations."""
45+
manager = IntegrationManager()
46+
await manager.load_configurations(db)
47+
48+
response = []
49+
for integration_id, integration in manager.registry.items():
50+
result = await db.execute(
51+
select(DbIntegrationConfig).where(DbIntegrationConfig.id == integration_id)
52+
)
53+
db_cfg = result.scalar_one_or_none()
54+
55+
response.append(
56+
{
57+
"id": integration_id,
58+
"name": integration.get_name(),
59+
"enabled": db_cfg.enabled if db_cfg else False,
60+
"config_json": db_cfg.config_json if db_cfg else {},
61+
}
62+
)
63+
return response
64+
65+
66+
@router.post("/{integration_id}/configure", response_model=IntegrationStatusResponse)
67+
async def configure_integration(
68+
integration_id: str,
69+
req: IntegrationConfigUpdate,
70+
db: AsyncSession = Depends(get_session), # noqa: B008
71+
) -> dict[str, Any]:
72+
"""Configure and enable/disable a specific integration."""
73+
manager = IntegrationManager()
74+
if integration_id not in manager.registry:
75+
raise HTTPException(
76+
status_code=status.HTTP_404_NOT_FOUND,
77+
detail=f"Integration '{integration_id}' not found",
78+
)
79+
80+
result = await db.execute(
81+
select(DbIntegrationConfig).where(DbIntegrationConfig.id == integration_id)
82+
)
83+
db_cfg = result.scalar_one_or_none()
84+
85+
if not db_cfg:
86+
db_cfg = DbIntegrationConfig(
87+
id=integration_id,
88+
enabled=req.enabled,
89+
config_json=req.config_json,
90+
)
91+
db.add(db_cfg)
92+
else:
93+
db_cfg.enabled = req.enabled
94+
db_cfg.config_json = req.config_json
95+
96+
await db.commit()
97+
98+
await manager.load_configurations(db)
99+
integration = manager.registry[integration_id]
100+
101+
return {
102+
"id": integration_id,
103+
"name": integration.get_name(),
104+
"enabled": db_cfg.enabled,
105+
"config_json": db_cfg.config_json,
106+
}
107+
108+
109+
@router.post("/dispatch")
110+
async def dispatch_event(
111+
req: DispatchTestRequest,
112+
db: AsyncSession = Depends(get_session), # noqa: B008
113+
) -> dict[str, Any]:
114+
"""Dispatch a test event to all active integrations."""
115+
manager = IntegrationManager()
116+
await manager.load_configurations(db)
117+
outcomes = await manager.dispatch(req.event_type, req.payload)
118+
return {"outcomes": outcomes}

‎src/docsgraph_server/db/alembic/env.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@
99

1010
from docsgraph_server.core.config import get_settings
1111
from docsgraph_server.db.base import Base
12-
13-
# Import model modules here so they register on Base.metadata before
14-
# Alembic's autogenerate inspects it, e.g.:
15-
# from docsgraph_server.documents.models import Document # noqa: ERA001
12+
from docsgraph_server.db.models import DbIntegrationConfig # noqa: F401
1613

1714
config = context.config
1815

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""create_integrations_table.
2+
3+
Revision ID: 03a58b392db2
4+
Revises: 0001
5+
Create Date: 2026-08-26 10:37:36.116178
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = "03a58b392db2"
15+
down_revision: str | None = "0001"
16+
branch_labels: str | Sequence[str] | None = None
17+
depends_on: str | Sequence[str] | None = None
18+
19+
20+
def upgrade() -> None:
21+
"""Create integration_configs table."""
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_table(
24+
"integration_configs",
25+
sa.Column("id", sa.String(length=100), nullable=False),
26+
sa.Column("enabled", sa.Boolean(), nullable=False),
27+
sa.Column("config_json", sa.JSON(), nullable=False),
28+
sa.PrimaryKeyConstraint("id"),
29+
)
30+
# ### end Alembic commands ###
31+
32+
33+
def downgrade() -> None:
34+
"""Drop integration_configs table."""
35+
# ### commands auto generated by Alembic - please adjust! ###
36+
op.drop_table("integration_configs")
37+
# ### end Alembic commands ###

‎src/docsgraph_server/db/models.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""SQLAlchemy ORM models for external integrations configuration."""
2+
3+
from typing import Any
4+
5+
from sqlalchemy import JSON, Boolean, String
6+
from sqlalchemy.orm import Mapped, mapped_column
7+
8+
from docsgraph_server.db.base import Base
9+
10+
11+
class DbIntegrationConfig(Base):
12+
"""SQLAlchemy model for storing external integration configurations."""
13+
14+
__tablename__ = "integration_configs"
15+
16+
id: Mapped[str] = mapped_column(String(100), primary_key=True)
17+
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
18+
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# docsgraph-server External Integrations Framework
2+
3+
The integrations framework enables `docsgraph-server` to connect with third-party external services (such as cloud storage, single sign-on, and event notifier channels) without modifying core server logic.
4+
5+
---
6+
7+
## 1. Core Interfaces
8+
9+
All integrations must conform to the `Integration` protocol:
10+
11+
```python
12+
from typing import Any, Protocol
13+
14+
15+
class Integration(Protocol):
16+
def get_id(self) -> str:
17+
"""Return the unique string identifier for the integration (e.g. 'webhook_notifier')."""
18+
...
19+
20+
def get_name(self) -> str:
21+
"""Return a user-friendly name for configuration UIs."""
22+
...
23+
24+
def configure(self, config: dict[str, Any]) -> None:
25+
"""Apply a dynamic configuration dictionary containing options and credentials."""
26+
...
27+
28+
def is_enabled(self) -> bool:
29+
"""Return whether the integration is configured and active."""
30+
...
31+
32+
async def execute(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
33+
"""Perform the integration execution on a dispatched event."""
34+
...
35+
```
36+
37+
---
38+
39+
## 2. Reference Implementation: Webhook Event Notifier
40+
41+
The `WebhookNotifierIntegration` is pre-registered and provides a secure push mechanism for external systems.
42+
43+
### Features
44+
- **HMAC Signatures**: Supports payload authentication. When `secret_key` is supplied, payloads are signed via HMAC-SHA256 and sent in the `X-Docsgraph-Signature` HTTP header.
45+
- **Async Execution**: Non-blocking network I/O powered by `httpx.AsyncClient`.
46+
47+
---
48+
49+
## 3. Configuration & Database Mapping
50+
Integrations are configured at runtime (without changing core code or rebooting the server) via database records in the `integration_configs` table:
51+
52+
- `id` (PK): The unique integration ID matching `get_id()`.
53+
- `enabled`: Global toggle to enable/disable.
54+
- `config_json`: JSON object containing connection parameters, credentials, or URLs.
55+
56+
API routes under `/api/v1/integrations` allow listing, configuring, and testing integrations.
57+
58+
---
59+
60+
## 4. Failure Isolation & Graceful Degradation
61+
To prevent failing external integrations from blocking or breaking core operations, all execution dispatches are isolated. The `IntegrationManager` catches exceptions on execution, logs them, and returns an outcome status without propagation.

0 commit comments

Comments
 (0)