use std::collections::HashMap;
use std::sync::Mutex;
use crate::calendar::CalendarClient;
use crate::custom::CustomIntegration;
use crate::drive::DriveClient;
use crate::gmail::GmailClient;
use crate::types::{
ApiResponse, ConnectionStatus, CustomMcpServerConfig, LeashError, DEFAULT_PLATFORM_URL,
};
pub struct LeashIntegrations {
pub(crate) platform_url: String,
pub(crate) auth_token: String,
pub(crate) api_key: Option<String>,
pub(crate) http: reqwest::Client,
env_cache: Mutex<Option<HashMap<String, String>>>,
}
impl LeashIntegrations {
pub fn new(auth_token: impl Into<String>) -> Self {
Self {
platform_url: DEFAULT_PLATFORM_URL.to_string(),
auth_token: auth_token.into(),
api_key: std::env::var("LEASH_API_KEY").ok(),
http: reqwest::Client::new(),
env_cache: Mutex::new(None),
}
}
pub fn with_platform_url(mut self, url: impl Into<String>) -> Self {
self.platform_url = url.into().trim_end_matches('/').to_string();
self
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
self
}
pub fn gmail(&self) -> GmailClient<'_> {
GmailClient { client: self }
}
pub fn calendar(&self) -> CalendarClient<'_> {
CalendarClient { client: self }
}
pub fn drive(&self) -> DriveClient<'_> {
DriveClient { client: self }
}
pub fn integration(&self, name: &str) -> CustomIntegration<'_> {
CustomIntegration::new(name, self)
}
pub async fn call(
&self,
provider: &str,
action: &str,
body: Option<serde_json::Value>,
) -> Result<serde_json::Value, LeashError> {
self.call_internal(provider, action, body).await
}
pub(crate) async fn call_internal(
&self,
provider: &str,
action: &str,
body: Option<serde_json::Value>,
) -> Result<serde_json::Value, LeashError> {
let url = format!(
"{}/api/integrations/{}/{}",
self.platform_url, provider, action
);
let mut req = self
.http
.post(&url)
.header("Content-Type", "application/json")
.bearer_auth(&self.auth_token);
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
Ok(api_resp.data.unwrap_or(serde_json::Value::Null))
}
pub async fn is_connected(&self, provider_id: &str) -> bool {
match self.get_connections().await {
Ok(connections) => connections
.iter()
.any(|c| c.provider_id == provider_id && c.status == "active"),
Err(_) => false,
}
}
pub async fn get_connections(&self) -> Result<Vec<ConnectionStatus>, LeashError> {
let url = format!("{}/api/integrations/connections", self.platform_url);
let mut req = self.http.get(&url);
if !self.auth_token.is_empty() {
req = req.bearer_auth(&self.auth_token);
}
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
let data = api_resp.data.unwrap_or(serde_json::Value::Null);
let connections: Vec<ConnectionStatus> =
serde_json::from_value(data).map_err(|e| LeashError::ApiError {
message: format!("failed to parse connections: {e}"),
code: None,
})?;
Ok(connections)
}
pub async fn mcp(
&self,
package: &str,
tool: &str,
args: serde_json::Value,
) -> Result<serde_json::Value, LeashError> {
let url = format!("{}/api/mcp/run", self.platform_url);
let payload = serde_json::json!({
"package": package,
"tool": tool,
"args": args,
});
let mut req = self
.http
.post(&url)
.header("Content-Type", "application/json")
.json(&payload);
if !self.auth_token.is_empty() {
req = req.bearer_auth(&self.auth_token);
}
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
Ok(api_resp.data.unwrap_or(serde_json::Value::Null))
}
pub async fn get_env(&self) -> Result<HashMap<String, String>, LeashError> {
{
let cache = self.env_cache.lock().unwrap();
if let Some(ref cached) = *cache {
return Ok(cached.clone());
}
}
let url = format!("{}/api/apps/env", self.platform_url);
let mut req = self.http.get(&url);
if !self.auth_token.is_empty() {
req = req.bearer_auth(&self.auth_token);
}
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
let data = api_resp.data.unwrap_or(serde_json::Value::Null);
let env_map: HashMap<String, String> =
serde_json::from_value(data).map_err(|e| LeashError::ApiError {
message: format!("failed to parse env data: {e}"),
code: None,
})?;
{
let mut cache = self.env_cache.lock().unwrap();
*cache = Some(env_map.clone());
}
Ok(env_map)
}
pub async fn get_env_key(&self, key: &str) -> Result<Option<String>, LeashError> {
let env_map = self.get_env().await?;
Ok(env_map.get(key).cloned())
}
pub async fn get_access_token(&self, provider: &str) -> Result<String, LeashError> {
let url = format!("{}/api/integrations/token", self.platform_url);
let payload = serde_json::json!({ "provider": provider });
let mut req = self
.http
.post(&url)
.header("Content-Type", "application/json")
.bearer_auth(&self.auth_token)
.json(&payload);
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
let data = api_resp.data.unwrap_or(serde_json::Value::Null);
let access_token = data
.get("accessToken")
.and_then(|v| v.as_str())
.ok_or_else(|| LeashError::ApiError {
message: "missing accessToken in response".to_string(),
code: None,
})?
.to_string();
Ok(access_token)
}
pub async fn get_custom_mcp_config(
&self,
slug: &str,
) -> Result<CustomMcpServerConfig, LeashError> {
let url = format!("{}/api/integrations/mcp-config/{}", self.platform_url, slug);
let mut req = self.http.get(&url);
if !self.auth_token.is_empty() {
req = req.bearer_auth(&self.auth_token);
}
if let Some(ref key) = self.api_key {
req = req.header("X-API-Key", key);
}
let resp = req.send().await?;
let api_resp: ApiResponse = resp.json().await?;
if !api_resp.success {
return Err(api_resp.into_error());
}
let data = api_resp.data.unwrap_or(serde_json::Value::Null);
let config: CustomMcpServerConfig =
serde_json::from_value(data).map_err(|e| LeashError::ApiError {
message: format!("failed to parse mcp config: {e}"),
code: None,
})?;
Ok(config)
}
pub fn get_connect_url(&self, provider_id: &str, return_url: Option<&str>) -> String {
let base = format!(
"{}/api/integrations/connect/{}",
self.platform_url, provider_id
);
match return_url {
Some(url) => {
let encoded = urlencoding_encode(url);
format!("{base}?return_url={encoded}")
}
None => base,
}
}
}
fn urlencoding_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len() * 2);
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
}
out
}