This commit is contained in:
parent
1530a8707d
commit
79a6f180a8
1 changed files with 489 additions and 37 deletions
|
|
@ -7,12 +7,14 @@
|
||||||
// The Composio API key is stored in the encrypted secret store.
|
// The Composio API key is stored in the encrypted secret store.
|
||||||
|
|
||||||
use super::traits::{Tool, ToolResult};
|
use super::traits::{Tool, ToolResult};
|
||||||
|
use anyhow::Context;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
const COMPOSIO_API_BASE: &str = "https://backend.composio.dev/api/v2";
|
const COMPOSIO_API_BASE_V2: &str = "https://backend.composio.dev/api/v2";
|
||||||
|
const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3";
|
||||||
|
|
||||||
/// A tool that proxies actions to the Composio managed tool platform.
|
/// A tool that proxies actions to the Composio managed tool platform.
|
||||||
pub struct ComposioTool {
|
pub struct ComposioTool {
|
||||||
|
|
@ -33,11 +35,50 @@ impl ComposioTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List available Composio apps/actions for the authenticated user.
|
/// List available Composio apps/actions for the authenticated user.
|
||||||
|
///
|
||||||
|
/// Uses v3 endpoint first and falls back to v2 for compatibility.
|
||||||
pub async fn list_actions(
|
pub async fn list_actions(
|
||||||
&self,
|
&self,
|
||||||
app_name: Option<&str>,
|
app_name: Option<&str>,
|
||||||
) -> anyhow::Result<Vec<ComposioAction>> {
|
) -> anyhow::Result<Vec<ComposioAction>> {
|
||||||
let mut url = format!("{COMPOSIO_API_BASE}/actions");
|
match self.list_actions_v3(app_name).await {
|
||||||
|
Ok(items) => Ok(items),
|
||||||
|
Err(v3_err) => {
|
||||||
|
let v2 = self.list_actions_v2(app_name).await;
|
||||||
|
match v2 {
|
||||||
|
Ok(items) => Ok(items),
|
||||||
|
Err(v2_err) => anyhow::bail!(
|
||||||
|
"Composio action listing failed on v3 ({v3_err}) and v2 fallback ({v2_err})"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_actions_v3(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
|
||||||
|
let url = format!("{COMPOSIO_API_BASE_V3}/tools");
|
||||||
|
let mut req = self.client.get(&url).header("x-api-key", &self.api_key);
|
||||||
|
|
||||||
|
req = req.query(&[("limit", 200_u16)]);
|
||||||
|
if let Some(app) = app_name {
|
||||||
|
req = req.query(&[("toolkit_slug", app)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req.send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = response_error(resp).await;
|
||||||
|
anyhow::bail!("Composio v3 API error: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ComposioToolsResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v3 tools response")?;
|
||||||
|
Ok(map_v3_tools_to_actions(body.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_actions_v2(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
|
||||||
|
let mut url = format!("{COMPOSIO_API_BASE_V2}/actions");
|
||||||
if let Some(app) = app_name {
|
if let Some(app) = app_name {
|
||||||
url = format!("{url}?appNames={app}");
|
url = format!("{url}?appNames={app}");
|
||||||
}
|
}
|
||||||
|
|
@ -50,22 +91,85 @@ impl ComposioTool {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await.unwrap_or_default();
|
let err = response_error(resp).await;
|
||||||
anyhow::bail!("Composio API error: {err}");
|
anyhow::bail!("Composio v2 API error: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: ComposioActionsResponse = resp.json().await?;
|
let body: ComposioActionsResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v2 actions response")?;
|
||||||
Ok(body.items)
|
Ok(body.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a Composio action by name with given parameters.
|
/// Execute a Composio action/tool with given parameters.
|
||||||
|
///
|
||||||
|
/// Uses v3 endpoint first and falls back to v2 for compatibility.
|
||||||
pub async fn execute_action(
|
pub async fn execute_action(
|
||||||
&self,
|
&self,
|
||||||
action_name: &str,
|
action_name: &str,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
entity_id: Option<&str>,
|
entity_id: Option<&str>,
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
let url = format!("{COMPOSIO_API_BASE}/actions/{action_name}/execute");
|
let tool_slug = normalize_tool_slug(action_name);
|
||||||
|
|
||||||
|
match self
|
||||||
|
.execute_action_v3(&tool_slug, params.clone(), entity_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(v3_err) => match self.execute_action_v2(action_name, params, entity_id).await {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(v2_err) => anyhow::bail!(
|
||||||
|
"Composio execute failed on v3 ({v3_err}) and v2 fallback ({v2_err})"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_action_v3(
|
||||||
|
&self,
|
||||||
|
tool_slug: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
entity_id: Option<&str>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{tool_slug}");
|
||||||
|
|
||||||
|
let mut body = json!({
|
||||||
|
"arguments": params,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(entity) = entity_id {
|
||||||
|
body["user_id"] = json!(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("x-api-key", &self.api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = response_error(resp).await;
|
||||||
|
anyhow::bail!("Composio v3 action execution failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v3 execute response")?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_action_v2(
|
||||||
|
&self,
|
||||||
|
action_name: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
entity_id: Option<&str>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let url = format!("{COMPOSIO_API_BASE_V2}/actions/{action_name}/execute");
|
||||||
|
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
"input": params,
|
"input": params,
|
||||||
|
|
@ -84,21 +188,96 @@ impl ComposioTool {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await.unwrap_or_default();
|
let err = response_error(resp).await;
|
||||||
anyhow::bail!("Composio action execution failed: {err}");
|
anyhow::bail!("Composio v2 action execution failed: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: serde_json::Value = resp.json().await?;
|
let result: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v2 execute response")?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the OAuth connection URL for a specific app.
|
/// Get the OAuth connection URL for a specific app/toolkit or auth config.
|
||||||
|
///
|
||||||
|
/// Uses v3 endpoint first and falls back to v2 for compatibility.
|
||||||
pub async fn get_connection_url(
|
pub async fn get_connection_url(
|
||||||
|
&self,
|
||||||
|
app_name: Option<&str>,
|
||||||
|
auth_config_id: Option<&str>,
|
||||||
|
entity_id: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let v3 = self
|
||||||
|
.get_connection_url_v3(app_name, auth_config_id, entity_id)
|
||||||
|
.await;
|
||||||
|
match v3 {
|
||||||
|
Ok(url) => Ok(url),
|
||||||
|
Err(v3_err) => {
|
||||||
|
let app = app_name.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Composio v3 connect failed ({v3_err}) and v2 fallback requires 'app'"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match self.get_connection_url_v2(app, entity_id).await {
|
||||||
|
Ok(url) => Ok(url),
|
||||||
|
Err(v2_err) => anyhow::bail!(
|
||||||
|
"Composio connect failed on v3 ({v3_err}) and v2 fallback ({v2_err})"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_connection_url_v3(
|
||||||
|
&self,
|
||||||
|
app_name: Option<&str>,
|
||||||
|
auth_config_id: Option<&str>,
|
||||||
|
entity_id: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let auth_config_id = match auth_config_id {
|
||||||
|
Some(id) => id.to_string(),
|
||||||
|
None => {
|
||||||
|
let app = app_name.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Missing 'app' or 'auth_config_id' for v3 connect")
|
||||||
|
})?;
|
||||||
|
self.resolve_auth_config_id(app).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{COMPOSIO_API_BASE_V3}/connected_accounts/link");
|
||||||
|
let body = json!({
|
||||||
|
"auth_config_id": auth_config_id,
|
||||||
|
"user_id": entity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("x-api-key", &self.api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = response_error(resp).await;
|
||||||
|
anyhow::bail!("Composio v3 connect failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v3 connect response")?;
|
||||||
|
extract_redirect_url(&result)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No redirect URL in Composio v3 response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_connection_url_v2(
|
||||||
&self,
|
&self,
|
||||||
app_name: &str,
|
app_name: &str,
|
||||||
entity_id: &str,
|
entity_id: &str,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let url = format!("{COMPOSIO_API_BASE}/connectedAccounts");
|
let url = format!("{COMPOSIO_API_BASE_V2}/connectedAccounts");
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"integrationId": app_name,
|
"integrationId": app_name,
|
||||||
|
|
@ -114,16 +293,57 @@ impl ComposioTool {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await.unwrap_or_default();
|
let err = response_error(resp).await;
|
||||||
anyhow::bail!("Failed to get connection URL: {err}");
|
anyhow::bail!("Composio v2 connect failed: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: serde_json::Value = resp.json().await?;
|
let result: serde_json::Value = resp
|
||||||
result
|
.json()
|
||||||
.get("redirectUrl")
|
.await
|
||||||
.and_then(|v| v.as_str())
|
.context("Failed to decode Composio v2 connect response")?;
|
||||||
.map(String::from)
|
extract_redirect_url(&result)
|
||||||
.ok_or_else(|| anyhow::anyhow!("No redirect URL in response"))
|
.ok_or_else(|| anyhow::anyhow!("No redirect URL in Composio v2 response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_auth_config_id(&self, app_name: &str) -> anyhow::Result<String> {
|
||||||
|
let url = format!("{COMPOSIO_API_BASE_V3}/auth_configs");
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &self.api_key)
|
||||||
|
.query(&[
|
||||||
|
("toolkit_slug", app_name),
|
||||||
|
("show_disabled", "true"),
|
||||||
|
("limit", "25"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = response_error(resp).await;
|
||||||
|
anyhow::bail!("Composio v3 auth config lookup failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ComposioAuthConfigsResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to decode Composio v3 auth configs response")?;
|
||||||
|
|
||||||
|
if body.items.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"No auth config found for toolkit '{app_name}'. Create one in Composio first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferred = body
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|cfg| cfg.is_enabled())
|
||||||
|
.or_else(|| body.items.first())
|
||||||
|
.context("No usable auth config returned by Composio")?;
|
||||||
|
|
||||||
|
Ok(preferred.id.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +355,8 @@ impl Tool for ComposioTool {
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
|
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
|
||||||
Use action='list' to see available actions, or action='execute' with action_name and params."
|
Use action='list' to see available actions, action='execute' with action_name/tool_slug and params, \
|
||||||
|
or action='connect' with app/auth_config_id to get OAuth URL."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
|
@ -149,11 +370,15 @@ impl Tool for ComposioTool {
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "App name filter for 'list', or app name for 'connect' (e.g. 'gmail', 'notion', 'github')"
|
"description": "Toolkit slug filter for 'list', or toolkit/app for 'connect' (e.g. 'gmail', 'notion', 'github')"
|
||||||
},
|
},
|
||||||
"action_name": {
|
"action_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The Composio action name to execute (e.g. 'GMAIL_FETCH_EMAILS')"
|
"description": "Action/tool identifier to execute (legacy aliases supported)"
|
||||||
|
},
|
||||||
|
"tool_slug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Preferred v3 tool slug to execute (alias of action_name)"
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -161,7 +386,11 @@ impl Tool for ComposioTool {
|
||||||
},
|
},
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Entity ID for multi-user setups (defaults to 'default')"
|
"description": "Entity/user ID for multi-user setups (defaults to 'default')"
|
||||||
|
},
|
||||||
|
"auth_config_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional Composio v3 auth config id for connect flow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
|
|
@ -222,9 +451,12 @@ impl Tool for ComposioTool {
|
||||||
|
|
||||||
"execute" => {
|
"execute" => {
|
||||||
let action_name = args
|
let action_name = args
|
||||||
.get("action_name")
|
.get("tool_slug")
|
||||||
|
.or_else(|| args.get("action_name"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'action_name' for execute"))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Missing 'action_name' (or 'tool_slug') for execute")
|
||||||
|
})?;
|
||||||
|
|
||||||
let params = args.get("params").cloned().unwrap_or(json!({}));
|
let params = args.get("params").cloned().unwrap_or(json!({}));
|
||||||
|
|
||||||
|
|
@ -250,17 +482,26 @@ impl Tool for ComposioTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
"connect" => {
|
"connect" => {
|
||||||
let app = args
|
let app = args.get("app").and_then(|v| v.as_str());
|
||||||
.get("app")
|
let auth_config_id = args.get("auth_config_id").and_then(|v| v.as_str());
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'app' for connect"))?;
|
|
||||||
|
|
||||||
match self.get_connection_url(app, entity_id).await {
|
if app.is_none() && auth_config_id.is_none() {
|
||||||
Ok(url) => Ok(ToolResult {
|
anyhow::bail!("Missing 'app' or 'auth_config_id' for connect");
|
||||||
success: true,
|
}
|
||||||
output: format!("Open this URL to connect {app}:\n{url}"),
|
|
||||||
error: None,
|
match self
|
||||||
}),
|
.get_connection_url(app, auth_config_id, entity_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(url) => {
|
||||||
|
let target =
|
||||||
|
app.unwrap_or(auth_config_id.unwrap_or("provided auth config"));
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Open this URL to connect {target}:\n{url}"),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(e) => Ok(ToolResult {
|
Err(e) => Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
|
|
@ -280,6 +521,74 @@ impl Tool for ComposioTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_tool_slug(action_name: &str) -> String {
|
||||||
|
action_name.trim().replace('_', "-").to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_v3_tools_to_actions(items: Vec<ComposioV3Tool>) -> Vec<ComposioAction> {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.slug.or(item.name.clone())?;
|
||||||
|
let app_name = item
|
||||||
|
.toolkit
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|toolkit| toolkit.slug.clone().or(toolkit.name.clone()))
|
||||||
|
.or(item.app_name);
|
||||||
|
let description = item.description.or(item.name);
|
||||||
|
Some(ComposioAction {
|
||||||
|
name,
|
||||||
|
app_name,
|
||||||
|
description,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_redirect_url(result: &serde_json::Value) -> Option<String> {
|
||||||
|
result
|
||||||
|
.get("redirect_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| result.get("redirectUrl").and_then(|v| v.as_str()))
|
||||||
|
.or_else(|| {
|
||||||
|
result
|
||||||
|
.get("data")
|
||||||
|
.and_then(|v| v.get("redirect_url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
})
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn response_error(resp: reqwest::Response) -> String {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return format!("HTTP {}", status.as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(api_error) = extract_api_error_message(&body) {
|
||||||
|
format!("HTTP {}: {api_error}", status.as_u16())
|
||||||
|
} else {
|
||||||
|
format!("HTTP {}: {body}", status.as_u16())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_api_error_message(body: &str) -> Option<String> {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||||
|
parsed
|
||||||
|
.get("error")
|
||||||
|
.and_then(|v| v.get("message"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
parsed
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── API response types ──────────────────────────────────────────
|
// ── API response types ──────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -288,6 +597,59 @@ struct ComposioActionsResponse {
|
||||||
items: Vec<ComposioAction>,
|
items: Vec<ComposioAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ComposioToolsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<ComposioV3Tool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct ComposioV3Tool {
|
||||||
|
#[serde(default)]
|
||||||
|
slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(rename = "appName", default)]
|
||||||
|
app_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
toolkit: Option<ComposioToolkitRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct ComposioToolkitRef {
|
||||||
|
#[serde(default)]
|
||||||
|
slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ComposioAuthConfigsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<ComposioAuthConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct ComposioAuthConfig {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComposioAuthConfig {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled.unwrap_or(false)
|
||||||
|
|| self
|
||||||
|
.status
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| v.eq_ignore_ascii_case("enabled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ComposioAction {
|
pub struct ComposioAction {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -323,8 +685,10 @@ mod tests {
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
assert!(schema["properties"]["action"].is_object());
|
assert!(schema["properties"]["action"].is_object());
|
||||||
assert!(schema["properties"]["action_name"].is_object());
|
assert!(schema["properties"]["action_name"].is_object());
|
||||||
|
assert!(schema["properties"]["tool_slug"].is_object());
|
||||||
assert!(schema["properties"]["params"].is_object());
|
assert!(schema["properties"]["params"].is_object());
|
||||||
assert!(schema["properties"]["app"].is_object());
|
assert!(schema["properties"]["app"].is_object());
|
||||||
|
assert!(schema["properties"]["auth_config_id"].is_object());
|
||||||
let required = schema["required"].as_array().unwrap();
|
let required = schema["required"].as_array().unwrap();
|
||||||
assert!(required.contains(&json!("action")));
|
assert!(required.contains(&json!("action")));
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +726,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connect_without_app_returns_error() {
|
async fn connect_without_target_returns_error() {
|
||||||
let tool = ComposioTool::new("test-key");
|
let tool = ComposioTool::new("test-key");
|
||||||
let result = tool.execute(json!({"action": "connect"})).await;
|
let result = tool.execute(json!({"action": "connect"})).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
@ -400,4 +764,92 @@ mod tests {
|
||||||
let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap();
|
let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap();
|
||||||
assert!(resp.items.is_empty());
|
assert!(resp.items.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composio_v3_tools_response_maps_to_actions() {
|
||||||
|
let json_str = r#"{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"slug": "gmail-fetch-emails",
|
||||||
|
"name": "Gmail Fetch Emails",
|
||||||
|
"description": "Fetch inbox emails",
|
||||||
|
"toolkit": { "slug": "gmail", "name": "Gmail" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
|
||||||
|
let actions = map_v3_tools_to_actions(resp.items);
|
||||||
|
assert_eq!(actions.len(), 1);
|
||||||
|
assert_eq!(actions[0].name, "gmail-fetch-emails");
|
||||||
|
assert_eq!(actions[0].app_name.as_deref(), Some("gmail"));
|
||||||
|
assert_eq!(
|
||||||
|
actions[0].description.as_deref(),
|
||||||
|
Some("Fetch inbox emails")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_tool_slug_supports_legacy_action_name() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tool_slug("GMAIL_FETCH_EMAILS"),
|
||||||
|
"gmail-fetch-emails"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tool_slug(" github-list-repos "),
|
||||||
|
"github-list-repos"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_redirect_url_supports_v2_and_v3_shapes() {
|
||||||
|
let v2 = json!({"redirectUrl": "https://app.composio.dev/connect-v2"});
|
||||||
|
let v3 = json!({"redirect_url": "https://app.composio.dev/connect-v3"});
|
||||||
|
let nested = json!({"data": {"redirect_url": "https://app.composio.dev/connect-nested"}});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_redirect_url(&v2).as_deref(),
|
||||||
|
Some("https://app.composio.dev/connect-v2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_redirect_url(&v3).as_deref(),
|
||||||
|
Some("https://app.composio.dev/connect-v3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_redirect_url(&nested).as_deref(),
|
||||||
|
Some("https://app.composio.dev/connect-nested")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_config_prefers_enabled_status() {
|
||||||
|
let enabled = ComposioAuthConfig {
|
||||||
|
id: "cfg_1".into(),
|
||||||
|
status: Some("ENABLED".into()),
|
||||||
|
enabled: None,
|
||||||
|
};
|
||||||
|
let disabled = ComposioAuthConfig {
|
||||||
|
id: "cfg_2".into(),
|
||||||
|
status: Some("DISABLED".into()),
|
||||||
|
enabled: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(enabled.is_enabled());
|
||||||
|
assert!(!disabled.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_api_error_message_from_common_shapes() {
|
||||||
|
let nested = r#"{"error":{"message":"tool not found"}}"#;
|
||||||
|
let flat = r#"{"message":"invalid api key"}"#;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_api_error_message(nested).as_deref(),
|
||||||
|
Some("tool not found")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_api_error_message(flat).as_deref(),
|
||||||
|
Some("invalid api key")
|
||||||
|
);
|
||||||
|
assert_eq!(extract_api_error_message("not-json"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue