Files
blackroad-operating-system/backend/app/routers/railway.py
Claude 84ab793177 Add comprehensive multi-API integration support
This commit adds extensive API integration capabilities for deployment,
payments, communications, and monitoring to BlackRoad OS.

New API Integrations:
- Railway API: Cloud deployment management (GraphQL)
- Vercel API: Serverless deployment platform (REST)
- Stripe API: Payment processing and billing
- Twilio API: SMS, Voice, and WhatsApp messaging
- Slack API: Team collaboration and notifications
- Discord API: Community messaging and notifications
- Sentry API: Error tracking and application monitoring

Core Features:
- Centralized API client manager with health checking
- Comprehensive health monitoring endpoint (/api/health/*)
- Automatic retry logic and rate limit handling
- Unified status monitoring for all integrations

Infrastructure:
- Railway deployment configuration (railway.json, railway.toml)
- Enhanced GitHub Actions workflows:
  * backend-tests.yml: Comprehensive test suite with PostgreSQL/Redis
  * railway-deploy.yml: Automated Railway deployment with notifications
- Docker build validation in CI/CD pipeline

Testing:
- Comprehensive test suite for all API integrations
- API connectivity verification in CI/CD
- Mock-friendly architecture for testing without credentials

Configuration:
- Updated .env.example with all new API keys
- Added stripe and sentry-sdk to requirements.txt
- Registered all new routers in main.py
- Updated API info endpoint with new integrations

Documentation:
- API_INTEGRATIONS.md: Complete setup and usage guide
- Interactive API docs at /api/docs with all endpoints
- Health check endpoints for monitoring

All APIs are optional and gracefully handle missing credentials.
The system provides clear status messages for configuration requirements.
2025-11-16 09:34:14 +00:00

394 lines
11 KiB
Python

"""
Railway API Integration Router
Provides endpoints for managing Railway deployments, projects, and services.
Railway is a deployment platform that simplifies infrastructure management.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from datetime import datetime
import httpx
import os
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/railway", tags=["railway"])
# Railway API configuration
RAILWAY_API_URL = "https://backboard.railway.app/graphql"
RAILWAY_TOKEN = os.getenv("RAILWAY_TOKEN")
class RailwayProject(BaseModel):
"""Railway project model"""
id: str
name: str
description: Optional[str] = None
created_at: datetime
updated_at: datetime
class RailwayService(BaseModel):
"""Railway service model"""
id: str
name: str
project_id: str
status: str
created_at: datetime
class RailwayDeployment(BaseModel):
"""Railway deployment model"""
id: str
service_id: str
status: str
created_at: datetime
url: Optional[str] = None
environment: str = "production"
class DeploymentCreate(BaseModel):
"""Create deployment request"""
project_id: str
service_id: str
environment: Optional[str] = "production"
class RailwayVariable(BaseModel):
"""Environment variable"""
key: str
value: str
class RailwayClient:
"""Railway GraphQL API client"""
def __init__(self, token: Optional[str] = None):
self.token = token or RAILWAY_TOKEN
self.api_url = RAILWAY_API_URL
async def _graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict[str, Any]:
"""Execute GraphQL request"""
if not self.token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Railway API token not configured"
)
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
payload = {
"query": query,
"variables": variables or {}
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
self.api_url,
json=payload,
headers=headers,
timeout=30.0
)
response.raise_for_status()
data = response.json()
if "errors" in data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"GraphQL error: {data['errors']}"
)
return data.get("data", {})
except httpx.HTTPError as e:
logger.error(f"Railway API error: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Railway API request failed: {str(e)}"
)
async def get_projects(self) -> List[Dict[str, Any]]:
"""Get all projects"""
query = """
query {
projects {
edges {
node {
id
name
description
createdAt
updatedAt
}
}
}
}
"""
result = await self._graphql_request(query)
edges = result.get("projects", {}).get("edges", [])
return [edge["node"] for edge in edges]
async def get_project(self, project_id: str) -> Optional[Dict[str, Any]]:
"""Get project by ID"""
query = """
query($id: String!) {
project(id: $id) {
id
name
description
createdAt
updatedAt
}
}
"""
result = await self._graphql_request(query, {"id": project_id})
return result.get("project")
async def get_services(self, project_id: str) -> List[Dict[str, Any]]:
"""Get services for a project"""
query = """
query($projectId: String!) {
project(id: $projectId) {
services {
edges {
node {
id
name
createdAt
}
}
}
}
}
"""
result = await self._graphql_request(query, {"projectId": project_id})
edges = result.get("project", {}).get("services", {}).get("edges", [])
return [edge["node"] for edge in edges]
async def get_deployments(self, service_id: str) -> List[Dict[str, Any]]:
"""Get deployments for a service"""
query = """
query($serviceId: String!) {
service(id: $serviceId) {
deployments {
edges {
node {
id
status
createdAt
url
}
}
}
}
}
"""
result = await self._graphql_request(query, {"serviceId": service_id})
edges = result.get("service", {}).get("deployments", {}).get("edges", [])
return [edge["node"] for edge in edges]
async def trigger_deployment(self, service_id: str) -> Dict[str, Any]:
"""Trigger a new deployment"""
query = """
mutation($serviceId: String!) {
serviceDeploy(serviceId: $serviceId) {
id
status
createdAt
}
}
"""
result = await self._graphql_request(query, {"serviceId": service_id})
return result.get("serviceDeploy", {})
async def set_variables(
self,
project_id: str,
environment_id: str,
variables: Dict[str, str]
) -> bool:
"""Set environment variables"""
query = """
mutation($projectId: String!, $environmentId: String!, $variables: String!) {
variableCollectionUpsert(
input: {
projectId: $projectId
environmentId: $environmentId
variables: $variables
}
)
}
"""
import json
variables_json = json.dumps(variables)
result = await self._graphql_request(
query,
{
"projectId": project_id,
"environmentId": environment_id,
"variables": variables_json
}
)
return bool(result.get("variableCollectionUpsert"))
# Initialize client
railway_client = RailwayClient()
@router.get("/status")
async def get_railway_status():
"""Get Railway API connection status"""
if not RAILWAY_TOKEN:
return {
"connected": False,
"message": "Railway API token not configured. Set RAILWAY_TOKEN environment variable."
}
try:
# Try to fetch projects as a health check
projects = await railway_client.get_projects()
return {
"connected": True,
"message": "Railway API connected successfully",
"project_count": len(projects)
}
except Exception as e:
return {
"connected": False,
"message": f"Railway API connection failed: {str(e)}"
}
@router.get("/projects", response_model=List[Dict[str, Any]])
async def list_projects():
"""List all Railway projects"""
try:
projects = await railway_client.get_projects()
return projects
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching projects: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch projects: {str(e)}"
)
@router.get("/projects/{project_id}")
async def get_project(project_id: str):
"""Get project details"""
project = await railway_client.get_project(project_id)
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
return project
@router.get("/projects/{project_id}/services")
async def list_services(project_id: str):
"""List services in a project"""
try:
services = await railway_client.get_services(project_id)
return services
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching services: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch services: {str(e)}"
)
@router.get("/services/{service_id}/deployments")
async def list_deployments(service_id: str):
"""List deployments for a service"""
try:
deployments = await railway_client.get_deployments(service_id)
return deployments
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching deployments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch deployments: {str(e)}"
)
@router.post("/services/{service_id}/deploy")
async def deploy_service(service_id: str):
"""Trigger a new deployment for a service"""
try:
deployment = await railway_client.trigger_deployment(service_id)
return {
"success": True,
"deployment": deployment,
"message": "Deployment triggered successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error triggering deployment: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to trigger deployment: {str(e)}"
)
@router.post("/projects/{project_id}/variables")
async def update_variables(
project_id: str,
environment_id: str,
variables: List[RailwayVariable]
):
"""Update environment variables for a project"""
try:
variables_dict = {var.key: var.value for var in variables}
success = await railway_client.set_variables(
project_id,
environment_id,
variables_dict
)
if success:
return {
"success": True,
"message": "Variables updated successfully"
}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to update variables"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating variables: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update variables: {str(e)}"
)
@router.get("/health")
async def railway_health_check():
"""Railway API health check endpoint"""
return {
"service": "railway",
"status": "operational" if RAILWAY_TOKEN else "not_configured",
"timestamp": datetime.utcnow().isoformat()
}