use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct N8nConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub workflows: HashMap<String, N8nWorkflow>,
}
fn default_true() -> bool {
true
}
impl Default for N8nConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: "http://localhost:5678".to_string(),
api_key: String::new(),
workflows: HashMap::new(),
}
}
}
impl N8nConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
pub fn resolve_env_vars(&mut self) {
if self.api_key.is_empty() || self.api_key.starts_with('$') {
self.api_key = std::env::var("N8N_API_KEY").unwrap_or_default();
}
if self.base_url.starts_with('$') {
self.base_url = std::env::var("N8N_URL")
.unwrap_or_else(|_| "http://localhost:5678".to_string());
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct N8nWorkflow {
pub name: String,
pub id: String,
pub description: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub webhook_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct N8nExecution {
pub id: String,
pub status: N8nExecutionStatus,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub data: Option<serde_json::Value>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum N8nExecutionStatus {
Running,
Success,
Error,
Cancelled,
Waiting,
}
pub struct N8nClient {
config: N8nConfig,
}
impl N8nClient {
pub fn new(config: &N8nConfig) -> Result<Self> {
let mut config = config.clone();
config.resolve_env_vars();
Ok(Self { config })
}
pub fn is_configured(&self) -> bool {
self.config.enabled && !self.config.base_url.is_empty()
}
pub async fn execute_workflow(&self, name: &str, data: serde_json::Value) -> Result<N8nExecution> {
let workflow = self.config.workflows.get(name)
.ok_or_else(|| DrivenError::NotFound(format!("Workflow '{}' not found", name)))?;
if !workflow.enabled {
return Err(DrivenError::Config("Workflow is disabled".into()));
}
self.execute_workflow_by_id(&workflow.id, data).await
}
pub async fn execute_workflow_by_id(&self, id: &str, data: serde_json::Value) -> Result<N8nExecution> {
let url = format!("{}/api/v1/workflows/{}/execute", self.config.base_url, id);
let client = reqwest::Client::new();
let mut request = client.post(&url);
if !self.config.api_key.is_empty() {
request = request.header("X-N8N-API-KEY", &self.config.api_key);
}
let response = request
.json(&serde_json::json!({ "data": data }))
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!(
"N8N API error ({}): {}",
status, error_text
)));
}
let result: N8nExecutionResponse = response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))?;
Ok(N8nExecution {
id: result.data.id,
status: N8nExecutionStatus::Success,
started_at: result.data.started_at,
finished_at: result.data.finished_at,
data: result.data.data,
error: None,
})
}
pub async fn trigger_webhook(&self, name: &str, data: serde_json::Value) -> Result<serde_json::Value> {
let workflow = self.config.workflows.get(name)
.ok_or_else(|| DrivenError::NotFound(format!("Workflow '{}' not found", name)))?;
let webhook_path = workflow.webhook_path.as_ref()
.ok_or_else(|| DrivenError::Config("Workflow has no webhook path".into()))?;
let url = format!("{}/webhook/{}", self.config.base_url, webhook_path);
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&data)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
return Err(DrivenError::Api("Webhook trigger failed".into()));
}
response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))
}
pub async fn get_execution(&self, id: &str) -> Result<N8nExecution> {
let url = format!("{}/api/v1/executions/{}", self.config.base_url, id);
let client = reqwest::Client::new();
let mut request = client.get(&url);
if !self.config.api_key.is_empty() {
request = request.header("X-N8N-API-KEY", &self.config.api_key);
}
let response = request
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
return Err(DrivenError::Api("Failed to get execution".into()));
}
let result: N8nExecutionResponse = response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))?;
Ok(N8nExecution {
id: result.data.id,
status: N8nExecutionStatus::Success,
started_at: result.data.started_at,
finished_at: result.data.finished_at,
data: result.data.data,
error: None,
})
}
pub async fn list_workflows(&self) -> Result<Vec<N8nWorkflowInfo>> {
let url = format!("{}/api/v1/workflows", self.config.base_url);
let client = reqwest::Client::new();
let mut request = client.get(&url);
if !self.config.api_key.is_empty() {
request = request.header("X-N8N-API-KEY", &self.config.api_key);
}
let response = request
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
return Err(DrivenError::Api("Failed to list workflows".into()));
}
let result: WorkflowsResponse = response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))?;
Ok(result.data)
}
}
#[derive(Debug, Deserialize)]
struct N8nExecutionResponse {
data: N8nExecutionData,
}
#[derive(Debug, Deserialize)]
struct N8nExecutionData {
id: String,
started_at: Option<String>,
finished_at: Option<String>,
data: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct WorkflowsResponse {
data: Vec<N8nWorkflowInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct N8nWorkflowInfo {
pub id: String,
pub name: String,
pub active: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = N8nConfig::default();
assert!(config.enabled);
assert_eq!(config.base_url, "http://localhost:5678");
}
#[test]
fn test_client_creation() {
let config = N8nConfig::default();
let client = N8nClient::new(&config);
assert!(client.is_ok());
}
}