suna/backend/pipedream/api.py

586 lines
21 KiB
Python
Raw Normal View History

2025-07-08 21:18:49 +08:00
from fastapi import APIRouter, HTTPException, Depends, Query
2025-07-08 17:10:15 +08:00
from typing import List, Dict, Any, Optional
from pydantic import BaseModel
2025-07-08 21:18:49 +08:00
import asyncio
import time
2025-07-08 17:10:15 +08:00
from utils.logger import logger
from utils.auth_utils import get_current_user_id_from_jwt
from .client import get_pipedream_client
2025-07-09 02:40:58 +08:00
from .profiles import (
get_profile_manager,
PipedreamProfile,
CreateProfileRequest,
UpdateProfileRequest
)
2025-07-08 17:10:15 +08:00
router = APIRouter(prefix="/pipedream", tags=["pipedream"])
2025-07-09 02:40:58 +08:00
db = None
def initialize(database):
"""Initialize the pipedream API with database connection."""
global db
db = database
2025-07-08 17:10:15 +08:00
class CreateConnectionTokenRequest(BaseModel):
app: Optional[str] = None
class ConnectionTokenResponse(BaseModel):
success: bool
link: Optional[str] = None
token: Optional[str] = None
external_user_id: str
app: Optional[str] = None
expires_at: Optional[str] = None
error: Optional[str] = None
class ConnectionResponse(BaseModel):
success: bool
connections: List[Dict[str, Any]]
count: int
error: Optional[str] = None
class HealthCheckResponse(BaseModel):
status: str
project_id: str
environment: str
has_access_token: bool
error: Optional[str] = None
class TriggerWorkflowRequest(BaseModel):
workflow_id: str
payload: Dict[str, Any]
class TriggerWorkflowResponse(BaseModel):
success: bool
workflow_id: str
run_id: Optional[str] = None
status: Optional[str] = None
error: Optional[str] = None
class MCPDiscoveryRequest(BaseModel):
app_slug: Optional[str] = None
oauth_app_id: Optional[str] = None
2025-07-09 02:40:58 +08:00
class MCPProfileDiscoveryRequest(BaseModel):
external_user_id: str
app_slug: Optional[str] = None
oauth_app_id: Optional[str] = None
2025-07-08 17:10:15 +08:00
class MCPDiscoveryResponse(BaseModel):
success: bool
mcp_servers: List[Dict[str, Any]]
count: int
error: Optional[str] = None
class MCPConnectionRequest(BaseModel):
app_slug: str
oauth_app_id: Optional[str] = None
class MCPConnectionResponse(BaseModel):
success: bool
mcp_config: Optional[Dict[str, Any]] = None
error: Optional[str] = None
@router.post("/connection-token", response_model=ConnectionTokenResponse)
async def create_connection_token(
request: CreateConnectionTokenRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Creating Pipedream connection token for user: {user_id}, app: {request.app}")
try:
client = get_pipedream_client()
result = await client.create_connection_token(user_id, request.app)
logger.info(f"Successfully created connection token for user: {user_id}")
return ConnectionTokenResponse(
success=True,
link=result.get("connect_link_url"),
token=result.get("token"),
external_user_id=user_id,
app=request.app,
expires_at=result.get("expires_at")
)
except Exception as e:
logger.error(f"Failed to create connection token for user {user_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to create connection token: {str(e)}"
)
@router.get("/connections", response_model=ConnectionResponse)
async def get_user_connections(
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Getting Pipedream connections for user: {user_id}")
try:
client = get_pipedream_client()
connections = await client.get_connections(user_id)
logger.info(f"Successfully retrieved {len(connections)} connections for user: {user_id}")
return ConnectionResponse(
success=True,
connections=connections,
count=len(connections)
)
except Exception as e:
logger.error(f"Failed to get connections for user {user_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get connections: {str(e)}"
)
@router.post("/mcp/discover", response_model=MCPDiscoveryResponse)
async def discover_mcp_servers(
request: MCPDiscoveryRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Discovering MCP servers for user: {user_id}, app: {request.app_slug}")
try:
client = get_pipedream_client()
mcp_servers = await client.discover_mcp_servers(
external_user_id=user_id,
app_slug=request.app_slug,
oauth_app_id=request.oauth_app_id
)
logger.info(f"Successfully discovered {len(mcp_servers)} MCP servers for user: {user_id}")
return MCPDiscoveryResponse(
success=True,
mcp_servers=mcp_servers,
count=len(mcp_servers)
)
except Exception as e:
logger.error(f"Failed to discover MCP servers for user {user_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to discover MCP servers: {str(e)}"
)
2025-07-09 02:40:58 +08:00
@router.post("/mcp/discover-profile", response_model=MCPDiscoveryResponse)
async def discover_mcp_servers_for_profile(
request: MCPProfileDiscoveryRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Discover MCP servers for a specific profile's external_user_id"""
logger.info(f"Discovering MCP servers for external_user_id: {request.external_user_id}, app: {request.app_slug}")
try:
client = get_pipedream_client()
mcp_servers = await client.discover_mcp_servers(
external_user_id=request.external_user_id,
app_slug=request.app_slug,
oauth_app_id=request.oauth_app_id
)
logger.info(f"Successfully discovered {len(mcp_servers)} MCP servers for external_user_id: {request.external_user_id}")
return MCPDiscoveryResponse(
success=True,
mcp_servers=mcp_servers,
count=len(mcp_servers)
)
except Exception as e:
logger.error(f"Failed to discover MCP servers for external_user_id {request.external_user_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to discover MCP servers: {str(e)}"
)
2025-07-08 17:10:15 +08:00
@router.post("/mcp/connect", response_model=MCPConnectionResponse)
async def create_mcp_connection(
request: MCPConnectionRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Creating MCP connection for user: {user_id}, app: {request.app_slug}")
try:
client = get_pipedream_client()
mcp_config = await client.create_mcp_connection(
external_user_id=user_id,
app_slug=request.app_slug,
oauth_app_id=request.oauth_app_id
)
logger.info(f"Successfully created MCP connection for user: {user_id}, app: {request.app_slug}")
return MCPConnectionResponse(
success=True,
mcp_config=mcp_config
)
except Exception as e:
logger.error(f"Failed to create MCP connection for user {user_id}, app {request.app_slug}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to create MCP connection: {str(e)}"
)
2025-07-08 21:18:49 +08:00
@router.post("/mcp/discover-custom", response_model=Dict[str, Any])
async def discover_pipedream_mcp_tools(
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Discovering all Pipedream MCP tools for user: {user_id}")
try:
client = get_pipedream_client()
mcp_servers = await client.discover_mcp_servers(
external_user_id=user_id
)
custom_mcps = []
for server in mcp_servers:
if server.get('status') == 'connected':
custom_mcp = {
'name': server['app_name'],
'type': 'pipedream',
'config': {
'app_slug': server['app_slug'],
'external_user_id': user_id,
'oauth_app_id': server.get('oauth_app_id')
},
'tools': server.get('available_tools', []),
'count': len(server.get('available_tools', []))
}
custom_mcps.append(custom_mcp)
logger.info(f"Found {len(custom_mcps)} Pipedream MCP servers for user: {user_id}")
return {
"success": True,
"servers": custom_mcps,
"count": len(custom_mcps)
}
except Exception as e:
logger.error(f"Failed to discover Pipedream MCP tools for user {user_id}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to discover Pipedream MCP tools: {str(e)}"
)
@router.get("/mcp/available-tools", response_model=Dict[str, Any])
async def get_available_pipedream_tools(
user_id: str = Depends(get_current_user_id_from_jwt),
force_refresh: bool = Query(False, description="Force refresh tools from Pipedream")
):
logger.info(f"Getting available Pipedream MCP tools for user: {user_id}, force_refresh: {force_refresh}")
try:
client = get_pipedream_client()
# Add retry logic for better reliability
max_retries = 3
retry_delay = 1.0
for attempt in range(max_retries):
try:
# Refresh rate limit token if this is a retry or forced refresh
if attempt > 0 or force_refresh:
logger.info(f"Refreshing rate limit token (attempt {attempt + 1})")
await client.refresh_rate_limit_token()
# Discover MCP servers with timeout
mcp_servers = await client.discover_mcp_servers(
external_user_id=user_id
)
apps_with_tools = []
total_tools = 0
for server in mcp_servers:
if server.get('status') == 'connected':
tools = server.get('available_tools', [])
if tools: # Only include apps that actually have tools
app_info = {
'app_name': server['app_name'],
'app_slug': server['app_slug'],
'tools': tools,
'tool_count': len(tools)
}
apps_with_tools.append(app_info)
total_tools += len(tools)
logger.info(f"Found {len(tools)} tools for {server['app_name']}")
else:
logger.warning(f"App {server['app_name']} is connected but has no tools")
else:
logger.warning(f"App {server.get('app_name', 'unknown')} has status: {server.get('status')}")
logger.info(f"Successfully retrieved {len(apps_with_tools)} apps with {total_tools} total tools")
return {
"success": True,
"apps": apps_with_tools,
"total_apps": len(apps_with_tools),
"total_tools": total_tools,
"user_id": user_id,
"timestamp": int(time.time())
}
except Exception as e:
if attempt < max_retries - 1:
logger.warning(f"Attempt {attempt + 1} failed, retrying in {retry_delay}s: {str(e)}")
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
continue
else:
raise e
except Exception as e:
logger.error(f"Failed to get available Pipedream tools for user {user_id}: {str(e)}")
# Return a more detailed error response
error_message = str(e)
if "MCP not available" in error_message:
error_message = "MCP service is not available. Please check your configuration."
elif "No connected apps" in error_message:
error_message = "No apps are connected to your Pipedream account."
elif "timeout" in error_message.lower():
error_message = "Request timed out. Please try again."
elif "rate limit" in error_message.lower():
error_message = "Rate limit exceeded. Please wait a moment and try again."
return {
"success": False,
"error": error_message,
"apps": [],
"total_apps": 0,
"total_tools": 0,
"user_id": user_id,
"timestamp": int(time.time())
}
@router.get("/apps", response_model=Dict[str, Any])
async def get_pipedream_apps(
2025-07-10 22:44:09 +08:00
after: Optional[str] = Query(None, description="Cursor for pagination"),
2025-07-10 01:57:25 +08:00
q: Optional[str] = Query(None),
2025-07-08 21:18:49 +08:00
category: Optional[str] = Query(None)
):
2025-07-10 22:44:09 +08:00
logger.info(f"Fetching Pipedream apps registry, after: {after}, search: {q}")
2025-07-08 21:18:49 +08:00
try:
2025-07-10 22:44:09 +08:00
client = get_pipedream_client()
access_token = await client._obtain_access_token()
await client._ensure_rate_limit_token()
url = f"https://api.pipedream.com/v1/apps"
params = {}
if after:
params["after"] = after
if q:
params["q"] = q
if category:
params["category"] = category
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
response = await client._make_request_with_retry(
"GET",
url,
headers=headers,
params=params
)
data = response.json()
page_info = data.get("page_info", {})
# For cursor-based pagination, has_more is determined by presence of end_cursor
page_info["has_more"] = bool(page_info.get("end_cursor"))
logger.info(f"Successfully fetched {len(data.get('data', []))} apps from Pipedream registry")
logger.info(f"Pagination: after={after}, total_count={page_info.get('total_count', 0)}, current_count={page_info.get('count', 0)}, has_more={page_info['has_more']}, end_cursor={page_info.get('end_cursor', 'None')}")
return {
"success": True,
"apps": data.get("data", []),
"page_info": page_info,
"total_count": page_info.get("total_count", 0)
}
2025-07-08 21:18:49 +08:00
except Exception as e:
logger.error(f"Failed to fetch Pipedream apps: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch Pipedream apps: {str(e)}"
)
2025-07-09 02:40:58 +08:00
@router.post("/profiles", response_model=PipedreamProfile)
async def create_credential_profile(
request: CreateProfileRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Creating credential profile for user: {user_id}, app: {request.app_slug}")
try:
profile_manager = get_profile_manager(db)
profile = await profile_manager.create_profile(user_id, request)
logger.info(f"Successfully created credential profile: {profile.profile_id}")
return profile
except Exception as e:
logger.error(f"Failed to create credential profile: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to create credential profile: {str(e)}"
)
@router.get("/profiles", response_model=List[PipedreamProfile])
async def get_credential_profiles(
app_slug: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Getting credential profiles for user: {user_id}, app: {app_slug}")
try:
profile_manager = get_profile_manager(db)
profiles = await profile_manager.get_profiles(user_id, app_slug, is_active)
logger.info(f"Successfully retrieved {len(profiles)} credential profiles")
return profiles
except Exception as e:
logger.error(f"Failed to get credential profiles: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get credential profiles: {str(e)}"
)
@router.get("/profiles/{profile_id}", response_model=PipedreamProfile)
async def get_credential_profile(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Getting credential profile: {profile_id} for user: {user_id}")
try:
profile_manager = get_profile_manager(db)
profile = await profile_manager.get_profile(user_id, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
logger.info(f"Successfully retrieved credential profile: {profile_id}")
return profile
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get credential profile: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get credential profile: {str(e)}"
)
@router.put("/profiles/{profile_id}", response_model=PipedreamProfile)
async def update_credential_profile(
profile_id: str,
request: UpdateProfileRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
logger.info(f"Updating credential profile: {profile_id} for user: {user_id}")
try:
profile_manager = get_profile_manager(db)
profile = await profile_manager.update_profile(user_id, profile_id, request)
logger.info(f"Successfully updated credential profile: {profile_id}")
return profile
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update credential profile: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to update credential profile: {str(e)}"
)
@router.delete("/profiles/{profile_id}")
async def delete_credential_profile(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Delete a credential profile"""
logger.info(f"Deleting credential profile: {profile_id} for user: {user_id}")
try:
profile_manager = get_profile_manager(db)
deleted = await profile_manager.delete_profile(user_id, profile_id)
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
logger.info(f"Successfully deleted credential profile: {profile_id}")
return {"success": True, "message": "Profile deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete credential profile: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to delete credential profile: {str(e)}"
)
@router.post("/profiles/{profile_id}/connect")
async def connect_credential_profile(
profile_id: str,
app: Optional[str] = Query(None),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Generate connection token for a specific credential profile"""
logger.info(f"Connecting credential profile: {profile_id} for user: {user_id}")
try:
profile_manager = get_profile_manager(db)
result = await profile_manager.connect_profile(user_id, profile_id, app)
logger.info(f"Successfully generated connection token for profile: {profile_id}")
return result
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to connect credential profile: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to connect credential profile: {str(e)}"
)
@router.get("/profiles/{profile_id}/connections")
async def get_profile_connections(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get connections for a specific credential profile"""
logger.info(f"Getting connections for profile: {profile_id}, user: {user_id}")
try:
profile_manager = get_profile_manager(db)
connections = await profile_manager.get_profile_connections(user_id, profile_id)
logger.info(f"Successfully retrieved {len(connections)} connections for profile: {profile_id}")
return {
"success": True,
"connections": connections,
"count": len(connections)
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get profile connections: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get profile connections: {str(e)}"
)