use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainConfig {
pub enabled: bool,
pub max_chain_length: usize,
pub global_timeout_secs: u64,
pub enable_parallel_execution: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainContext {
#[serde(default)]
pub responses: HashMap<String, ChainResponse>,
#[serde(default)]
pub variables: HashMap<String, serde_json::Value>,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
impl ChainContext {
pub fn new() -> Self {
Self {
responses: HashMap::new(),
variables: HashMap::new(),
metadata: HashMap::new(),
}
}
pub fn store_response(&mut self, name: String, response: ChainResponse) {
self.responses.insert(name, response);
}
pub fn get_response(&self, name: &str) -> Option<&ChainResponse> {
self.responses.get(name)
}
pub fn set_variable(&mut self, name: String, value: serde_json::Value) {
self.variables.insert(name, value);
}
pub fn get_variable(&self, name: &str) -> Option<&serde_json::Value> {
self.variables.get(name)
}
pub fn set_metadata(&mut self, key: String, value: String) {
self.metadata.insert(key, value);
}
pub fn get_metadata(&self, key: &str) -> Option<&String> {
self.metadata.get(key)
}
}
impl Default for ChainContext {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestScripting {
pub pre_script: Option<String>,
pub post_script: Option<String>,
#[serde(default = "default_script_runtime")]
pub runtime: String,
#[serde(default = "default_script_timeout")]
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum RequestBody {
#[serde(rename = "json")]
Json(serde_json::Value),
#[serde(rename = "binary_file")]
BinaryFile {
path: String,
content_type: Option<String>,
},
}
impl RequestBody {
pub fn json(value: serde_json::Value) -> Self {
Self::Json(value)
}
pub fn binary_file(path: String, content_type: Option<String>) -> Self {
Self::BinaryFile { path, content_type }
}
pub async fn to_bytes(&self) -> Result<Vec<u8>> {
match self {
RequestBody::Json(value) => Ok(serde_json::to_vec(value)?),
RequestBody::BinaryFile { path, .. } => tokio::fs::read(path).await.map_err(|e| {
Error::io_with_context(format!("read binary file '{}'", path), e.to_string())
}),
}
}
pub fn content_type(&self) -> Option<&str> {
match self {
RequestBody::Json(_) => Some("application/json"),
RequestBody::BinaryFile { content_type, .. } => content_type.as_deref(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainRequest {
pub id: String,
pub method: String,
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: Option<RequestBody>,
#[serde(default)]
pub depends_on: Vec<String>,
pub timeout_secs: Option<u64>,
pub expected_status: Option<Vec<u16>>,
#[serde(default)]
pub scripting: Option<RequestScripting>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Option<serde_json::Value>,
pub duration_ms: u64,
pub executed_at: String,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainLink {
pub request: ChainRequest,
#[serde(default)]
pub extract: HashMap<String, String>,
pub store_as: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainDefinition {
pub id: String,
pub name: String,
pub description: Option<String>,
pub config: ChainConfig,
pub links: Vec<ChainLink>,
#[serde(default)]
pub variables: HashMap<String, serde_json::Value>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptExecutionContext {
pub chain_context: ChainContext,
pub request_variables: HashMap<String, serde_json::Value>,
pub current_request: Option<ChainRequest>,
pub current_response: Option<ChainResponse>,
}
#[derive(Debug, Clone)]
pub struct ChainTemplatingContext {
pub chain_context: ChainContext,
pub request_variables: HashMap<String, serde_json::Value>,
pub current_request: Option<ChainRequest>,
}
impl ChainTemplatingContext {
pub fn new(chain_context: ChainContext) -> Self {
Self {
chain_context,
request_variables: HashMap::new(),
current_request: None,
}
}
pub fn set_request_variable(&mut self, name: String, value: serde_json::Value) {
self.request_variables.insert(name, value);
}
pub fn set_current_request(&mut self, request: ChainRequest) {
self.current_request = Some(request);
}
pub fn extract_value(&self, path: &str) -> Option<serde_json::Value> {
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return None;
}
let root = parts[0];
let root_value = if let Some(resp) = self.chain_context.get_response(root) {
resp.body.clone()?
} else if let Some(var) = self.chain_context.get_variable(root) {
var.clone()
} else if let Some(var) = self.request_variables.get(root) {
var.clone()
} else {
return None;
};
self.navigate_json_path(&root_value, &parts[1..])
}
#[allow(clippy::only_used_in_recursion)]
fn navigate_json_path(
&self,
value: &serde_json::Value,
path: &[&str],
) -> Option<serde_json::Value> {
if path.is_empty() {
return Some(value.clone());
}
match value {
serde_json::Value::Object(map) => {
if let Some(next_value) = map.get(path[0]) {
self.navigate_json_path(next_value, &path[1..])
} else {
None
}
}
serde_json::Value::Array(arr) => {
if path[0].starts_with('[') && path[0].ends_with(']') {
let index_str = &path[0][1..path[0].len() - 1];
if let Ok(index) = index_str.parse::<usize>() {
if let Some(item) = arr.get(index) {
self.navigate_json_path(item, &path[1..])
} else {
None
}
} else {
None
}
} else {
None
}
}
_ => None,
}
}
}
#[derive(Debug)]
pub struct ChainStore {
chains: RwLock<HashMap<String, ChainDefinition>>,
config: ChainConfig,
}
impl ChainStore {
pub fn new(config: ChainConfig) -> Self {
Self {
chains: RwLock::new(HashMap::new()),
config,
}
}
pub async fn register_chain(&self, chain: ChainDefinition) -> Result<()> {
let mut chains = self.chains.write().await;
chains.insert(chain.id.clone(), chain);
Ok(())
}
pub async fn get_chain(&self, id: &str) -> Option<ChainDefinition> {
let chains = self.chains.read().await;
chains.get(id).cloned()
}
pub async fn list_chains(&self) -> Vec<String> {
let chains = self.chains.read().await;
chains.keys().cloned().collect()
}
pub async fn remove_chain(&self, id: &str) -> Result<()> {
let mut chains = self.chains.write().await;
chains.remove(id);
Ok(())
}
pub fn update_config(&mut self, config: ChainConfig) {
self.config = config;
}
}
#[derive(Debug)]
pub struct ChainExecutionContext {
pub definition: ChainDefinition,
pub templating: ChainTemplatingContext,
pub start_time: std::time::Instant,
pub config: ChainConfig,
}
impl ChainExecutionContext {
pub fn new(definition: ChainDefinition) -> Self {
let chain_context = ChainContext::new();
let templating = ChainTemplatingContext::new(chain_context);
let config = definition.config.clone();
Self {
definition,
templating,
start_time: std::time::Instant::now(),
config,
}
}
pub fn elapsed_ms(&self) -> u128 {
self.start_time.elapsed().as_millis()
}
}
#[derive(Debug)]
pub struct RequestChainRegistry {
store: ChainStore,
}
impl RequestChainRegistry {
pub fn new(config: ChainConfig) -> Self {
Self {
store: ChainStore::new(config),
}
}
pub async fn register_from_yaml(&self, yaml: &str) -> Result<String> {
let chain: ChainDefinition = serde_yaml::from_str(yaml)
.map_err(|e| Error::config(format!("Failed to parse chain YAML: {}", e)))?;
self.store.register_chain(chain.clone()).await?;
Ok(chain.id.clone())
}
pub async fn register_from_json(&self, json: &str) -> Result<String> {
let chain: ChainDefinition = serde_json::from_str(json)
.map_err(|e| Error::config(format!("Failed to parse chain JSON: {}", e)))?;
self.store.register_chain(chain.clone()).await?;
Ok(chain.id.clone())
}
pub async fn get_chain(&self, id: &str) -> Option<ChainDefinition> {
self.store.get_chain(id).await
}
pub async fn list_chains(&self) -> Vec<String> {
self.store.list_chains().await
}
pub async fn remove_chain(&self, id: &str) -> Result<()> {
self.store.remove_chain(id).await
}
pub async fn validate_chain(&self, chain: &ChainDefinition) -> Result<()> {
if chain.links.is_empty() {
return Err(Error::validation("Chain must have at least one link"));
}
if chain.links.len() > self.store.config.max_chain_length {
return Err(Error::validation(format!(
"Chain length {} exceeds maximum allowed length {}",
chain.links.len(),
self.store.config.max_chain_length
)));
}
let mut visited = std::collections::HashSet::new();
let mut rec_stack = std::collections::HashSet::new();
for link in &chain.links {
self.validate_link_dependencies(link, &mut visited, &mut rec_stack, chain)?;
}
let request_ids: std::collections::HashSet<_> =
chain.links.iter().map(|link| &link.request.id).collect();
if request_ids.len() != chain.links.len() {
return Err(Error::validation("Duplicate request IDs found in chain"));
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn validate_link_dependencies(
&self,
link: &ChainLink,
visited: &mut std::collections::HashSet<String>,
rec_stack: &mut std::collections::HashSet<String>,
chain: &ChainDefinition,
) -> Result<()> {
if rec_stack.contains(&link.request.id) {
return Err(Error::validation(format!(
"Circular dependency detected involving request '{}'",
link.request.id
)));
}
if visited.contains(&link.request.id) {
return Ok(());
}
visited.insert(link.request.id.clone());
rec_stack.insert(link.request.id.clone());
for dep in &link.request.depends_on {
if !chain.links.iter().any(|l| &l.request.id == dep) {
return Err(Error::validation(format!(
"Request '{}' depends on '{}' which does not exist in the chain",
link.request.id, dep
)));
}
if let Some(dep_link) = chain.links.iter().find(|l| &l.request.id == dep) {
self.validate_link_dependencies(dep_link, visited, rec_stack, chain)?;
}
}
rec_stack.remove(&link.request.id);
Ok(())
}
pub fn store(&self) -> &ChainStore {
&self.store
}
}
fn default_script_runtime() -> String {
"javascript".to_string()
}
fn default_script_timeout() -> u64 {
5000 }
impl Default for ChainConfig {
fn default() -> Self {
Self {
enabled: false,
max_chain_length: 20,
global_timeout_secs: 300,
enable_parallel_execution: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_chain_context() {
let mut ctx = ChainContext::new();
ctx.set_variable("user_id".to_string(), json!("12345"));
assert_eq!(ctx.get_variable("user_id"), Some(&json!("12345")));
let response = ChainResponse {
status: 200,
headers: HashMap::new(),
body: Some(json!({"user": {"id": 123, "name": "John"}})),
duration_ms: 150,
executed_at: "2023-01-01T00:00:00Z".to_string(),
error: None,
};
ctx.store_response("login".to_string(), response.clone());
assert_eq!(ctx.get_response("login"), Some(&response));
}
#[test]
fn test_chain_context_comprehensive() {
let mut ctx = ChainContext::new();
ctx.set_variable("user_id".to_string(), json!("12345"));
ctx.set_variable("token".to_string(), json!("abc-def-ghi"));
ctx.set_variable("environment".to_string(), json!("production"));
ctx.set_variable("timeout".to_string(), json!(30));
assert_eq!(ctx.get_variable("user_id"), Some(&json!("12345")));
assert_eq!(ctx.get_variable("token"), Some(&json!("abc-def-ghi")));
assert_eq!(ctx.get_variable("environment"), Some(&json!("production")));
assert_eq!(ctx.get_variable("timeout"), Some(&json!(30)));
assert_eq!(ctx.get_variable("nonexistent"), None);
ctx.set_variable("user_id".to_string(), json!("67890"));
assert_eq!(ctx.get_variable("user_id"), Some(&json!("67890")));
ctx.set_metadata("chain_id".to_string(), "test-chain-123".to_string());
ctx.set_metadata("version".to_string(), "1.0.0".to_string());
assert_eq!(ctx.get_metadata("chain_id"), Some(&"test-chain-123".to_string()));
assert_eq!(ctx.get_metadata("version"), Some(&"1.0.0".to_string()));
let response1 = ChainResponse {
status: 200,
headers: vec![("Content-Type".to_string(), "application/json".to_string())]
.into_iter()
.collect(),
body: Some(json!({"message": "success1"})),
duration_ms: 100,
executed_at: "2023-01-01T00:00:00Z".to_string(),
error: None,
};
let response2 = ChainResponse {
status: 201,
headers: vec![("Location".to_string(), "/users/123".to_string())].into_iter().collect(),
body: Some(json!({"id": 123, "name": "John"})),
duration_ms: 150,
executed_at: "2023-01-01T00:00:01Z".to_string(),
error: None,
};
ctx.store_response("step1".to_string(), response1.clone());
ctx.store_response("step2".to_string(), response2.clone());
assert_eq!(ctx.get_response("step1"), Some(&response1));
assert_eq!(ctx.get_response("step2"), Some(&response2));
assert_eq!(ctx.get_response("nonexistent"), None);
let updated_response = ChainResponse {
status: 202,
headers: HashMap::new(),
body: Some(json!({"message": "updated"})),
duration_ms: 200,
executed_at: "2023-01-01T00:00:02Z".to_string(),
error: None,
};
ctx.store_response("step1".to_string(), updated_response.clone());
assert_eq!(ctx.get_response("step1"), Some(&updated_response));
}
#[test]
fn test_chain_context_serialization() {
let mut ctx = ChainContext::new();
ctx.set_variable("test_var".to_string(), json!("test_value"));
ctx.set_metadata("test_meta".to_string(), "test_value".to_string());
let response = ChainResponse {
status: 200,
headers: HashMap::new(),
body: Some(json!({"data": "test"})),
duration_ms: 100,
executed_at: "2023-01-01T00:00:00Z".to_string(),
error: None,
};
ctx.store_response("test_response".to_string(), response);
let json_str = serde_json::to_string(&ctx).unwrap();
assert!(json_str.contains("test_var"));
assert!(json_str.contains("test_value"));
assert!(json_str.contains("test_meta"));
assert!(json_str.contains("test_response"));
let deserialized: ChainContext = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.get_variable("test_var"), Some(&json!("test_value")));
assert_eq!(deserialized.get_metadata("test_meta"), Some(&"test_value".to_string()));
assert!(deserialized.get_response("test_response").is_some());
}
#[test]
fn test_chain_request_serialization() {
let request = ChainRequest {
id: "test-req".to_string(),
method: "POST".to_string(),
url: "https://api.example.com/test".to_string(),
headers: vec![("Content-Type".to_string(), "application/json".to_string())]
.into_iter()
.collect(),
body: Some(RequestBody::Json(json!({"key": "value"}))),
depends_on: vec!["req1".to_string(), "req2".to_string()],
timeout_secs: Some(30),
expected_status: Some(vec![200, 201, 202]),
scripting: Some(RequestScripting {
pre_script: Some("console.log('pre');".to_string()),
post_script: Some("console.log('post');".to_string()),
runtime: "javascript".to_string(),
timeout_ms: 5000,
}),
};
let json_str = serde_json::to_string(&request).unwrap();
assert!(json_str.contains("test-req"));
assert!(json_str.contains("POST"));
assert!(json_str.contains("Content-Type"));
assert!(json_str.contains("req1"));
assert!(json_str.contains("preScript"));
let deserialized: ChainRequest = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.id, request.id);
assert_eq!(deserialized.method, request.method);
assert_eq!(deserialized.depends_on, request.depends_on);
}
#[test]
fn test_chain_response_serialization() {
let response = ChainResponse {
status: 200,
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("X-Request-ID".to_string(), "req-123".to_string()),
]
.into_iter()
.collect(),
body: Some(json!({"result": "success", "data": [1, 2, 3]})),
duration_ms: 150,
executed_at: "2023-01-01T00:00:00Z".to_string(),
error: None,
};
let json_str = serde_json::to_string(&response).unwrap();
assert!(json_str.contains("200"));
assert!(json_str.contains("application/json"));
assert!(json_str.contains("success"));
let deserialized: ChainResponse = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.status, response.status);
assert_eq!(deserialized.duration_ms, response.duration_ms);
assert_eq!(deserialized.body, response.body);
}
#[test]
fn test_chain_response_with_error() {
let error_response = ChainResponse {
status: 500,
headers: HashMap::new(),
body: None,
duration_ms: 50,
executed_at: "2023-01-01T00:00:00Z".to_string(),
error: Some("Internal server error".to_string()),
};
let json_str = serde_json::to_string(&error_response).unwrap();
assert!(json_str.contains("500"));
assert!(json_str.contains("Internal server error"));
let deserialized: ChainResponse = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.error, Some("Internal server error".to_string()));
assert!(deserialized.body.is_none());
}
#[test]
fn test_request_body_types() {
let json_body = RequestBody::Json(json!({"key": "value", "number": 42}));
assert!(matches!(json_body, RequestBody::Json(_)));
let string_body =
RequestBody::Json(serde_json::Value::String("raw text content".to_string()));
assert!(matches!(string_body, RequestBody::Json(_)));
let binary_body = RequestBody::BinaryFile {
path: "/path/to/file.bin".to_string(),
content_type: Some("application/octet-stream".to_string()),
};
assert!(matches!(binary_body, RequestBody::BinaryFile { .. }));
let test_cases = vec![
RequestBody::Json(json!({"test": "json"})),
RequestBody::Json(serde_json::Value::String("test string".to_string())),
RequestBody::BinaryFile {
path: "/path/to/bytes.bin".to_string(),
content_type: None,
},
];
for body in test_cases {
let json_str = serde_json::to_string(&body).unwrap();
let deserialized: RequestBody = serde_json::from_str(&json_str).unwrap();
assert_eq!(format!("{:?}", body), format!("{:?}", deserialized));
}
}
#[test]
fn test_chain_link_dependencies() {
let link1 = ChainLink {
request: ChainRequest {
id: "req1".to_string(),
method: "GET".to_string(),
url: "https://api.example.com/users".to_string(),
headers: HashMap::new(),
body: None,
depends_on: vec![], timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: HashMap::new(),
store_as: Some("users".to_string()),
};
let link2 = ChainLink {
request: ChainRequest {
id: "req2".to_string(),
method: "POST".to_string(),
url: "https://api.example.com/posts".to_string(),
headers: HashMap::new(),
body: Some(RequestBody::Json(json!({"title": "Test"}))),
depends_on: vec!["req1".to_string()], timeout_secs: Some(30),
expected_status: Some(vec![200, 201]),
scripting: None,
},
extract: HashMap::new(),
store_as: Some("post".to_string()),
};
let link3 = ChainLink {
request: ChainRequest {
id: "req3".to_string(),
method: "PUT".to_string(),
url: "https://api.example.com/posts/{{chain.post.id}}".to_string(),
headers: HashMap::new(),
body: None,
depends_on: vec!["req1".to_string(), "req2".to_string()], timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: HashMap::new(),
store_as: None,
};
assert!(link1.request.depends_on.is_empty());
assert_eq!(link2.request.depends_on, vec!["req1".to_string()]);
assert_eq!(link3.request.depends_on, vec!["req1".to_string(), "req2".to_string()]);
}
#[test]
fn test_chain_config_validation() {
let valid_config = ChainConfig {
enabled: true,
max_chain_length: 10,
global_timeout_secs: 300,
enable_parallel_execution: true,
};
let invalid_config = ChainConfig {
enabled: true,
max_chain_length: 0, global_timeout_secs: 300,
enable_parallel_execution: true,
};
assert!(valid_config.max_chain_length > 0);
assert!(invalid_config.max_chain_length == 0);
let edge_config = ChainConfig {
enabled: false,
max_chain_length: 1,
global_timeout_secs: 0,
enable_parallel_execution: false,
};
assert_eq!(edge_config.max_chain_length, 1);
assert_eq!(edge_config.global_timeout_secs, 0);
assert!(!edge_config.enabled);
}
#[test]
fn test_request_scripting_config() {
let scripting = RequestScripting {
pre_script: Some("console.log('Starting request');".to_string()),
post_script: Some("console.log('Request completed');".to_string()),
runtime: "javascript".to_string(),
timeout_ms: 5000,
};
assert_eq!(scripting.runtime, "javascript");
assert_eq!(scripting.timeout_ms, 5000);
assert!(scripting.pre_script.is_some());
assert!(scripting.post_script.is_some());
let json_str = serde_json::to_string(&scripting).unwrap();
assert!(json_str.contains("javascript"));
assert!(json_str.contains("Starting request"));
assert!(json_str.contains("Request completed"));
let deserialized: RequestScripting = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.runtime, scripting.runtime);
assert_eq!(deserialized.timeout_ms, scripting.timeout_ms);
}
#[test]
fn test_chain_definition_structure() {
let definition = ChainDefinition {
id: "test-chain".to_string(),
name: "Test Chain".to_string(),
description: Some("A comprehensive test chain".to_string()),
config: ChainConfig::default(),
links: vec![ChainLink {
request: ChainRequest {
id: "req1".to_string(),
method: "GET".to_string(),
url: "https://api.example.com/users".to_string(),
headers: HashMap::new(),
body: None,
depends_on: vec![],
timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: vec![("user_id".to_string(), "$.users[0].id".to_string())]
.into_iter()
.collect(),
store_as: Some("users".to_string()),
}],
variables: vec![
("api_key".to_string(), json!("test-key")),
("base_url".to_string(), json!("https://api.example.com")),
]
.into_iter()
.collect(),
tags: vec!["test".to_string(), "integration".to_string()],
};
assert_eq!(definition.id, "test-chain");
assert_eq!(definition.name, "Test Chain");
assert!(definition.description.is_some());
assert_eq!(definition.links.len(), 1);
assert_eq!(definition.variables.len(), 2);
assert_eq!(definition.tags.len(), 2);
let json_str = serde_json::to_string(&definition).unwrap();
assert!(json_str.contains("test-chain"));
assert!(json_str.contains("Test Chain"));
assert!(json_str.contains("comprehensive test chain"));
assert!(json_str.contains("api_key"));
assert!(json_str.contains("test-key"));
}
#[test]
fn test_chain_execution_context() {
let chain_def = ChainDefinition {
id: "test_chain".to_string(),
name: "Test Chain".to_string(),
description: Some("Test chain for unit tests".to_string()),
config: ChainConfig::default(),
links: vec![],
tags: vec![],
variables: HashMap::new(),
};
let exec_ctx = ChainExecutionContext::new(chain_def);
std::thread::sleep(std::time::Duration::from_millis(1));
assert!(exec_ctx.elapsed_ms() > 0);
}
#[tokio::test]
async fn test_chain_definition_validation() {
let registry = RequestChainRegistry::new(ChainConfig::default());
let valid_chain = ChainDefinition {
id: "test-chain".to_string(),
name: "Test Chain".to_string(),
description: Some("A test chain for validation".to_string()),
config: ChainConfig::default(),
links: vec![
ChainLink {
request: ChainRequest {
id: "req1".to_string(),
method: "GET".to_string(),
url: "https://api.example.com/users".to_string(),
headers: HashMap::new(),
body: None,
depends_on: vec![],
timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: HashMap::new(),
store_as: Some("users".to_string()),
},
ChainLink {
request: ChainRequest {
id: "req2".to_string(),
method: "POST".to_string(),
url: "https://api.example.com/users/{{chain.users.body[0].id}}/posts"
.to_string(),
headers: HashMap::new(),
body: Some(RequestBody::Json(json!({"title": "Hello World"}))),
depends_on: vec!["req1".to_string()],
timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: HashMap::new(),
store_as: Some("post".to_string()),
},
],
variables: {
let mut vars = HashMap::new();
vars.insert("api_key".to_string(), json!("test-key-123"));
vars
},
tags: vec!["test".to_string()],
};
assert!(registry.validate_chain(&valid_chain).await.is_ok());
let invalid_chain = ChainDefinition {
id: "empty-chain".to_string(),
name: "Empty Chain".to_string(),
description: None,
config: ChainConfig::default(),
links: vec![],
variables: HashMap::new(),
tags: vec![],
};
assert!(registry.validate_chain(&invalid_chain).await.is_err());
let self_dep_chain = ChainDefinition {
id: "self-dep-chain".to_string(),
name: "Self Dependency Chain".to_string(),
description: None,
config: ChainConfig::default(),
links: vec![ChainLink {
request: ChainRequest {
id: "req1".to_string(),
method: "GET".to_string(),
url: "https://api.example.com/users".to_string(),
headers: HashMap::new(),
body: None,
depends_on: vec!["req1".to_string()], timeout_secs: None,
expected_status: None,
scripting: None,
},
extract: HashMap::new(),
store_as: None,
}],
variables: HashMap::new(),
tags: vec![],
};
assert!(registry.validate_chain(&self_dep_chain).await.is_err());
}
}