first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
363
backend/app/routes_integrations.py
Normal file
363
backend/app/routes_integrations.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .database import get_db
|
||||
from .endpoint_security import endpoint_signing_secret, validate_endpoint_url
|
||||
from .models import (
|
||||
PhoneNumber,
|
||||
SmsDeliveryWebhook,
|
||||
UserAccount,
|
||||
VoiceSocketEndpoint,
|
||||
)
|
||||
from .sms_webhooks import deliver_test_report
|
||||
from .voice_auth import current_voice_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
|
||||
|
||||
class EndpointCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
phone_number_id: UUID
|
||||
url: str = Field(min_length=8, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EndpointUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=120)
|
||||
phone_number_id: UUID | None = None
|
||||
url: str | None = Field(default=None, min_length=8, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
def _owned_number(db: Session, user: UserAccount, number_id: UUID) -> PhoneNumber:
|
||||
number = db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.id == number_id,
|
||||
PhoneNumber.user_id == user.id,
|
||||
PhoneNumber.status == "assigned",
|
||||
)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
|
||||
return number
|
||||
|
||||
|
||||
def _clean_name(value: str) -> str:
|
||||
name = " ".join(value.split())
|
||||
if len(name) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Name must contain at least 2 characters",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _commit(db: Session, duplicate_detail: str) -> None:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=duplicate_detail) from exc
|
||||
|
||||
|
||||
def _serialize_socket(endpoint: VoiceSocketEndpoint, *, include_secret: bool = False) -> dict:
|
||||
payload = {
|
||||
"id": str(endpoint.id),
|
||||
"name": endpoint.name,
|
||||
"phone_number_id": str(endpoint.phone_number_id),
|
||||
"phone_number": endpoint.phone_number.phone_number,
|
||||
"url": endpoint.url,
|
||||
"enabled": endpoint.enabled,
|
||||
"created_at": endpoint.created_at,
|
||||
"updated_at": endpoint.updated_at,
|
||||
}
|
||||
if include_secret:
|
||||
payload["signing_secret"] = endpoint_signing_secret("socket", endpoint.id)
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_webhook(endpoint: SmsDeliveryWebhook, *, include_secret: bool = False) -> dict:
|
||||
payload = {
|
||||
"id": str(endpoint.id),
|
||||
"name": endpoint.name,
|
||||
"phone_number_id": str(endpoint.phone_number_id),
|
||||
"phone_number": endpoint.phone_number.phone_number,
|
||||
"url": endpoint.url,
|
||||
"description": endpoint.description,
|
||||
"event": "message.delivery.updated",
|
||||
"enabled": endpoint.enabled,
|
||||
"created_at": endpoint.created_at,
|
||||
"updated_at": endpoint.updated_at,
|
||||
}
|
||||
if include_secret:
|
||||
payload["signing_secret"] = endpoint_signing_secret("webhook", endpoint.id)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/docs")
|
||||
def integration_docs() -> dict:
|
||||
return {
|
||||
"title": "Webhooks & Sockets",
|
||||
"summary": "Connect each Urmi number to your application.",
|
||||
"relay": {
|
||||
"principle": "Urmi forwards raw WebSocket and HTTP payloads without decoding or modifying them. We authenticate requests and route traffic — all processing happens on your infrastructure.",
|
||||
"latency": "Voice media frames are relayed as they arrive to keep latency as low as possible.",
|
||||
},
|
||||
"socket": {
|
||||
"title": "Voice sockets",
|
||||
"description": "Link a secure WebSocket endpoint to a number. When a call connects, Urmi opens your endpoint and relays frames in both directions with no audio processing on our side.",
|
||||
"events": ["connected", "start", "media", "end"],
|
||||
"authentication": {
|
||||
"headers": ["X-Urmi-Endpoint-Id", "X-Urmi-Timestamp", "X-Urmi-Signature"],
|
||||
"signature": "HMAC-SHA256 of '<endpoint_id>.<timestamp>' using the signing secret.",
|
||||
"replay_protection": "Reject timestamps older than five minutes.",
|
||||
},
|
||||
},
|
||||
"webhook": {
|
||||
"title": "SMS delivery webhooks",
|
||||
"description": "Link a named HTTPS endpoint to a number to receive SMS delivery status updates.",
|
||||
"event": "message.delivery.updated",
|
||||
"statuses": ["queued", "sent", "delivered", "undelivered", "failed"],
|
||||
"authentication": {
|
||||
"headers": ["X-Urmi-Event", "X-Urmi-Endpoint-Id", "X-Urmi-Signature"],
|
||||
"signature": "HMAC-SHA256 of the raw request body using the signing secret.",
|
||||
},
|
||||
"example": {
|
||||
"id": "evt_01J8EXAMPLE",
|
||||
"event": "message.delivery.updated",
|
||||
"created_at": "2026-09-04T09:30:00Z",
|
||||
"data": {
|
||||
"message_id": "msg_01J8EXAMPLE",
|
||||
"to": "+919999999999",
|
||||
"from": "+917946350706",
|
||||
"status": "delivered",
|
||||
"updated_at": "2026-09-04T09:29:58Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
"api_authentication": {
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<your_api_key>",
|
||||
"note": "Use API keys only from your server. Never expose them in browser code.",
|
||||
},
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/v1/integrations/sockets", "description": "List voice sockets"},
|
||||
{"method": "POST", "path": "/api/v1/integrations/sockets", "description": "Create and link a voice socket"},
|
||||
{"method": "PATCH", "path": "/api/v1/integrations/sockets/{id}", "description": "Update or pause a voice socket"},
|
||||
{"method": "DELETE", "path": "/api/v1/integrations/sockets/{id}", "description": "Delete a voice socket"},
|
||||
{"method": "GET", "path": "/api/v1/integrations/webhooks", "description": "List SMS delivery webhooks"},
|
||||
{"method": "POST", "path": "/api/v1/integrations/webhooks", "description": "Create and link a delivery webhook"},
|
||||
{"method": "PATCH", "path": "/api/v1/integrations/webhooks/{id}", "description": "Update or pause a delivery webhook"},
|
||||
{"method": "DELETE", "path": "/api/v1/integrations/webhooks/{id}", "description": "Delete a delivery webhook"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sockets")
|
||||
def list_sockets(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> list[dict]:
|
||||
endpoints = db.scalars(
|
||||
select(VoiceSocketEndpoint)
|
||||
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
||||
.where(VoiceSocketEndpoint.user_id == user.id)
|
||||
.order_by(VoiceSocketEndpoint.created_at.desc())
|
||||
).all()
|
||||
return [_serialize_socket(endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.post("/sockets", status_code=status.HTTP_201_CREATED)
|
||||
def create_socket(
|
||||
payload: EndpointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
number = _owned_number(db, user, payload.phone_number_id)
|
||||
try:
|
||||
url = validate_endpoint_url(payload.url, websocket=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
endpoint = VoiceSocketEndpoint(
|
||||
user_id=user.id,
|
||||
phone_number_id=number.id,
|
||||
name=_clean_name(payload.name),
|
||||
url=url,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
db.add(endpoint)
|
||||
_commit(db, "This name or phone number already has a voice socket")
|
||||
db.refresh(endpoint)
|
||||
endpoint.phone_number = number
|
||||
return _serialize_socket(endpoint, include_secret=True)
|
||||
|
||||
|
||||
@router.patch("/sockets/{endpoint_id}")
|
||||
def update_socket(
|
||||
endpoint_id: UUID,
|
||||
payload: EndpointUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(VoiceSocketEndpoint)
|
||||
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
||||
.where(VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
||||
if payload.name is not None:
|
||||
endpoint.name = _clean_name(payload.name)
|
||||
if payload.phone_number_id is not None:
|
||||
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
||||
endpoint.phone_number_id = payload.phone_number_id
|
||||
if payload.url is not None:
|
||||
try:
|
||||
endpoint.url = validate_endpoint_url(payload.url, websocket=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if payload.enabled is not None:
|
||||
endpoint.enabled = payload.enabled
|
||||
_commit(db, "This name or phone number already has a voice socket")
|
||||
db.refresh(endpoint)
|
||||
return _serialize_socket(endpoint)
|
||||
|
||||
|
||||
@router.delete("/sockets/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_socket(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> None:
|
||||
endpoint = db.scalar(
|
||||
select(VoiceSocketEndpoint).where(
|
||||
VoiceSocketEndpoint.id == endpoint_id,
|
||||
VoiceSocketEndpoint.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> list[dict]:
|
||||
endpoints = db.scalars(
|
||||
select(SmsDeliveryWebhook)
|
||||
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
||||
.where(SmsDeliveryWebhook.user_id == user.id)
|
||||
.order_by(SmsDeliveryWebhook.created_at.desc())
|
||||
).all()
|
||||
return [_serialize_webhook(endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.post("/webhooks", status_code=status.HTTP_201_CREATED)
|
||||
def create_webhook(
|
||||
payload: EndpointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
number = _owned_number(db, user, payload.phone_number_id)
|
||||
try:
|
||||
url = validate_endpoint_url(payload.url, websocket=False)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
endpoint = SmsDeliveryWebhook(
|
||||
user_id=user.id,
|
||||
phone_number_id=number.id,
|
||||
name=_clean_name(payload.name),
|
||||
url=url,
|
||||
description=payload.description.strip() if payload.description else None,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
db.add(endpoint)
|
||||
_commit(db, "A webhook with this name already exists")
|
||||
db.refresh(endpoint)
|
||||
endpoint.phone_number = number
|
||||
return _serialize_webhook(endpoint, include_secret=True)
|
||||
|
||||
|
||||
@router.patch("/webhooks/{endpoint_id}")
|
||||
def update_webhook(
|
||||
endpoint_id: UUID,
|
||||
payload: EndpointUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook)
|
||||
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
||||
.where(SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
if payload.name is not None:
|
||||
endpoint.name = _clean_name(payload.name)
|
||||
if payload.phone_number_id is not None:
|
||||
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
||||
endpoint.phone_number_id = payload.phone_number_id
|
||||
if payload.url is not None:
|
||||
try:
|
||||
endpoint.url = validate_endpoint_url(payload.url, websocket=False)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if "description" in payload.model_fields_set:
|
||||
endpoint.description = payload.description.strip() if payload.description else None
|
||||
if payload.enabled is not None:
|
||||
endpoint.enabled = payload.enabled
|
||||
_commit(db, "A webhook with this name already exists")
|
||||
db.refresh(endpoint)
|
||||
return _serialize_webhook(endpoint)
|
||||
|
||||
|
||||
@router.post("/webhooks/{endpoint_id}/test")
|
||||
async def test_webhook(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook).where(
|
||||
SmsDeliveryWebhook.id == endpoint_id,
|
||||
SmsDeliveryWebhook.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
try:
|
||||
response_code = await deliver_test_report(endpoint)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return {"delivered": True, "response_code": response_code}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_webhook(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> None:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook).where(
|
||||
SmsDeliveryWebhook.id == endpoint_id,
|
||||
SmsDeliveryWebhook.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
Loading…
Add table
Add a link
Reference in a new issue