use http::Method;
use serde_json::Value;
use std::{
collections::HashMap,
fmt,
path::PathBuf,
time::Duration,
};
mod assertion;
mod artifact;
mod executor;
mod planner;
mod response_capture;
mod runner;
mod session;
mod template;
pub use assertion::{
Assertion, AssertionDiff, AssertionDiffFormat, AssertionMismatch, AssertionMismatchValue,
};
pub use artifact::{
ArtifactMetadata, ArtifactTimingPhase, ArtifactTranscript, ArtifactTranscriptDirection,
ExecutionArtifact, HttpArtifactMetadata, RetainedArtifact,
};
pub use planner::RequestPlanner;
pub(crate) use response_capture::validate_redaction_body_path;
pub use response_capture::{ResponseCapture, ResponseSnapshot};
pub use runner::{
execute_plan as execute_request_plan,
execute_plan_with_observer as execute_request_plan_with_observer, ExecutionEvent,
ExecutionObserver, ExecutionOptions, ExecutionRecord, ExecutionTraceCollector,
ExecutionTraceEntry, ExecutionTraceKind, InterruptSignal, RequestFailure,
RequestFailureKind,
};
pub use session::{SessionHandle, SessionRegistry};
pub use template::{
expand_templates, FragmentInclude, GraphqlTemplateOperation, HttpTemplateOperation,
McpInitializeTemplate, McpTemplateCall, McpTemplateOperation, McpToolCallTemplate,
OAuthFieldMapping, OAuthProfile, RequestTemplate, RequestTemplateOperation,
SseTemplateAction, SseTemplateOperation, TemplateError, VariableStore,
WsSendKindTemplate, WsTemplateAction, WsTemplateOperation,
};
pub const DEFAULT_REQUEST_TIMEOUT: &str = "30s";
pub const DEFAULT_POLL_INTERVAL: &str = "1s";
#[derive(Debug, Clone)]
pub enum FormDataType {
Text(String),
File(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestProtocol {
Http,
Graphql,
Mcp,
Sse,
Ws,
}
impl RequestProtocol {
pub fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Graphql => "graphql",
Self::Mcp => "mcp",
Self::Sse => "sse",
Self::Ws => "ws",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RedactionRules {
additional_header_names: Vec<String>,
additional_capture_names: Vec<String>,
additional_body_paths: Vec<String>,
}
impl RedactionRules {
pub fn add_header_name(&mut self, name: &str) -> Result<(), String> {
let normalized = normalize_redaction_name(name);
if normalized.is_empty() {
return Err("redact_header requires a non-empty header name".to_string());
}
push_unique_value(&mut self.additional_header_names, normalized);
Ok(())
}
pub fn add_capture_name(&mut self, name: &str) -> Result<(), String> {
let normalized = normalize_redaction_name(name);
if normalized.is_empty() {
return Err("redact_capture requires a non-empty capture or export name".to_string());
}
push_unique_value(&mut self.additional_capture_names, normalized);
Ok(())
}
pub fn add_body_path(&mut self, raw_path: &str) -> Result<(), String> {
let trimmed = raw_path.trim();
if trimmed.is_empty() {
return Err("redact_body requires a non-empty body path".to_string());
}
push_unique_value(&mut self.additional_body_paths, trimmed.to_string());
Ok(())
}
pub fn additional_header_names(&self) -> &[String] {
&self.additional_header_names
}
pub fn additional_body_paths(&self) -> &[String] {
&self.additional_body_paths
}
pub fn should_redact_export_name(&self, name: &str) -> bool {
let normalized = normalize_redaction_name(name);
is_default_sensitive_export_name(&normalized)
|| self
.additional_capture_names
.iter()
.any(|candidate| candidate == &normalized)
}
}
fn push_unique_value(values: &mut Vec<String>, value: String) {
if values.iter().any(|existing| existing == &value) {
return;
}
values.push(value);
}
fn normalize_redaction_name(name: &str) -> String {
name.trim().to_ascii_lowercase()
}
fn is_default_sensitive_export_name(normalized: &str) -> bool {
matches!(
normalized,
"access_token"
| "api_key"
| "apikey"
| "auth_token"
| "authorization"
| "bearer_token"
| "client_secret"
| "cookie"
| "csrf_token"
| "id_token"
| "password"
| "refresh_token"
| "secret"
| "session_cookie"
| "session_id"
| "session_token"
| "token"
) || normalized.ends_with("_token")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_password")
|| normalized.ends_with("_cookie")
|| normalized.ends_with("_session_id")
|| normalized.ends_with("_api_key")
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestReliabilityPolicyTemplate {
pub timeout: Option<String>,
pub poll_until: Option<String>,
pub poll_every: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestReliabilityPolicy {
pub timeout: String,
pub poll_until: Option<String>,
pub poll_every: Option<String>,
}
impl Default for RequestReliabilityPolicy {
fn default() -> Self {
Self {
timeout: DEFAULT_REQUEST_TIMEOUT.to_string(),
poll_until: None,
poll_every: None,
}
}
}
fn parse_named_duration(raw: &str, label: &str) -> Result<Duration, String> {
let trimmed = raw.trim();
let parse_number = |value: &str| {
value.parse::<u64>().map_err(|_| {
format!(
"invalid {} '{}' (expected formats like 250ms, 2s, or 1m)",
label, raw
)
})
};
if let Some(value) = trimmed.strip_suffix("ms") {
return Ok(Duration::from_millis(parse_number(value.trim())?));
}
if let Some(value) = trimmed.strip_suffix('s') {
return Ok(Duration::from_secs(parse_number(value.trim())?));
}
if let Some(value) = trimmed.strip_suffix('m') {
return Ok(Duration::from_secs(parse_number(value.trim())? * 60));
}
Err(format!(
"invalid {} '{}' (expected formats like 250ms, 2s, or 1m)",
label, raw
))
}
pub(crate) fn parse_within_duration(raw: &str) -> Result<Duration, String> {
parse_named_duration(raw, "within duration")
}
pub(crate) fn parse_timeout_duration(raw: &str) -> Result<Duration, String> {
parse_named_duration(raw, "timeout duration")
}
pub(crate) fn parse_poll_duration(raw: &str, field: &str) -> Result<Duration, String> {
parse_named_duration(raw, format!("{} duration", field).as_str())
}
#[derive(Debug, Clone)]
pub struct HttpOperation {
pub method: Method,
pub url: String,
pub headers: HashMap<String, String>,
pub query_params: Vec<(String, String)>,
pub cookies: HashMap<String, String>,
pub form_data: HashMap<String, FormDataType>,
pub body: Option<String>,
pub body_content_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GraphqlOperation {
pub http: HttpOperation,
pub operation_name: Option<String>,
pub document: String,
pub variables_json: Option<String>,
}
#[derive(Debug, Clone)]
pub struct McpInitialize {
pub protocol_version: Option<String>,
pub client_name: Option<String>,
pub client_version: Option<String>,
pub capabilities_json: Option<String>,
}
#[derive(Debug, Clone)]
pub struct McpToolCall {
pub tool_name: String,
pub arguments_json: Option<String>,
}
#[derive(Debug, Clone)]
pub enum McpCall {
Initialize(McpInitialize),
ToolsList,
ResourcesList,
ToolsCall(McpToolCall),
}
impl McpCall {
pub fn method_name(&self) -> &'static str {
match self {
Self::Initialize(_) => "initialize",
Self::ToolsList => "tools/list",
Self::ResourcesList => "resources/list",
Self::ToolsCall(_) => "tools/call",
}
}
}
#[derive(Debug, Clone)]
pub struct McpOperation {
pub http: HttpOperation,
pub call: McpCall,
}
#[derive(Debug, Clone)]
pub enum SseAction {
Open,
Receive { within: String },
}
impl SseAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Receive { .. } => "receive",
}
}
}
#[derive(Debug, Clone)]
pub struct SseOperation {
pub http: HttpOperation,
pub action: SseAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WsSendKind {
Text,
Json,
}
impl WsSendKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
}
}
}
#[derive(Debug, Clone)]
pub enum WsAction {
Open,
Send { kind: WsSendKind, payload: String },
Exchange {
kind: WsSendKind,
payload: String,
within: String,
},
Receive { within: String },
}
impl WsAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Send { .. } => "send",
Self::Exchange { .. } => "exchange",
Self::Receive { .. } => "receive",
}
}
}
#[derive(Debug, Clone)]
pub struct WsOperation {
pub http: HttpOperation,
pub action: WsAction,
}
#[derive(Debug, Clone)]
pub enum RequestOperation {
Http(HttpOperation),
Graphql(GraphqlOperation),
Mcp(McpOperation),
Sse(SseOperation),
Ws(WsOperation),
}
impl RequestOperation {
pub fn protocol(&self) -> RequestProtocol {
match self {
Self::Http(_) => RequestProtocol::Http,
Self::Graphql(_) => RequestProtocol::Graphql,
Self::Mcp(_) => RequestProtocol::Mcp,
Self::Sse(_) => RequestProtocol::Sse,
Self::Ws(_) => RequestProtocol::Ws,
}
}
pub fn as_http(&self) -> Option<&HttpOperation> {
match self {
Self::Http(operation) => Some(operation),
Self::Graphql(operation) => Some(&operation.http),
Self::Mcp(operation) => Some(&operation.http),
Self::Sse(operation) => Some(&operation.http),
Self::Ws(operation) => Some(&operation.http),
}
}
pub fn as_http_mut(&mut self) -> Option<&mut HttpOperation> {
match self {
Self::Http(operation) => Some(operation),
Self::Graphql(operation) => Some(&mut operation.http),
Self::Mcp(operation) => Some(&mut operation.http),
Self::Sse(operation) => Some(&mut operation.http),
Self::Ws(operation) => Some(&mut operation.http),
}
}
pub fn as_graphql(&self) -> Option<&GraphqlOperation> {
match self {
Self::Graphql(operation) => Some(operation),
Self::Http(_) | Self::Mcp(_) | Self::Sse(_) | Self::Ws(_) => None,
}
}
pub fn as_mcp(&self) -> Option<&McpOperation> {
match self {
Self::Mcp(operation) => Some(operation),
Self::Http(_) | Self::Graphql(_) | Self::Sse(_) | Self::Ws(_) => None,
}
}
pub fn as_sse(&self) -> Option<&SseOperation> {
match self {
Self::Sse(operation) => Some(operation),
Self::Http(_) | Self::Graphql(_) | Self::Mcp(_) | Self::Ws(_) => None,
}
}
pub fn as_ws(&self) -> Option<&WsOperation> {
match self {
Self::Ws(operation) => Some(operation),
Self::Http(_) | Self::Graphql(_) | Self::Mcp(_) | Self::Sse(_) => None,
}
}
pub fn protocol_context_json(&self) -> Option<Value> {
match self {
Self::Http(_) => None,
Self::Graphql(operation) => operation.protocol_context_json(),
Self::Mcp(operation) => operation.protocol_context_json(),
Self::Sse(operation) => operation.protocol_context_json(),
Self::Ws(operation) => operation.protocol_context_json(),
}
}
}
#[derive(Debug, Clone)]
pub struct Request {
pub description: String,
pub base_description: String,
pub operation: RequestOperation,
pub reliability: RequestReliabilityPolicy,
pub session_name: Option<String>,
pub auth_profile_name: Option<String>,
pub auth_profile: Option<OAuthProfile>,
pub callback_src: Vec<String>,
pub response_captures: Vec<ResponseCapture>,
pub assertions: Vec<Assertion>,
pub declared_dependencies: Vec<String>,
pub dependencies: Vec<String>,
pub context: HashMap<String, String>,
pub redaction_rules: RedactionRules,
pub sensitive_values: Vec<String>,
pub working_dir: PathBuf,
pub map_iteration: Option<MapIteration>,
}
impl GraphqlOperation {
pub fn protocol_context_json(&self) -> Option<Value> {
graphql_protocol_context_json(
self.operation_name.as_deref(),
self.variables_json.as_deref(),
)
}
}
impl McpOperation {
pub fn protocol_context_json(&self) -> Option<Value> {
let mut context = serde_json::Map::new();
context.insert(
"call".to_string(),
Value::String(self.call.method_name().to_string()),
);
match &self.call {
McpCall::Initialize(initialize) => {
if let Some(protocol_version) = &initialize.protocol_version {
context.insert(
"protocolVersion".to_string(),
Value::String(protocol_version.clone()),
);
}
if let Some(client_name) = &initialize.client_name {
context.insert("clientName".to_string(), Value::String(client_name.clone()));
}
if let Some(client_version) = &initialize.client_version {
context.insert(
"clientVersion".to_string(),
Value::String(client_version.clone()),
);
}
if let Some(capabilities_json) = &initialize.capabilities_json {
insert_json_or_text_field(
&mut context,
"capabilities",
"capabilitiesText",
capabilities_json,
);
}
}
McpCall::ToolsList | McpCall::ResourcesList => {}
McpCall::ToolsCall(tool_call) => {
context.insert(
"tool".to_string(),
Value::String(tool_call.tool_name.clone()),
);
if let Some(arguments_json) = &tool_call.arguments_json {
insert_json_or_text_field(
&mut context,
"arguments",
"argumentsText",
arguments_json,
);
}
}
}
Some(Value::Object(context))
}
}
impl SseOperation {
pub fn protocol_context_json(&self) -> Option<Value> {
let mut context = serde_json::Map::new();
context.insert(
"action".to_string(),
Value::String(self.action.as_str().to_string()),
);
if let SseAction::Receive { within } = &self.action {
context.insert("within".to_string(), Value::String(within.clone()));
}
Some(Value::Object(context))
}
}
impl WsOperation {
pub fn protocol_context_json(&self) -> Option<Value> {
let mut context = serde_json::Map::new();
context.insert(
"action".to_string(),
Value::String(self.action.as_str().to_string()),
);
match &self.action {
WsAction::Open => {}
WsAction::Send { kind, .. } => {
context.insert("kind".to_string(), Value::String(kind.as_str().to_string()));
}
WsAction::Exchange { kind, within, .. } => {
context.insert("kind".to_string(), Value::String(kind.as_str().to_string()));
context.insert("within".to_string(), Value::String(within.clone()));
}
WsAction::Receive { within } => {
context.insert("within".to_string(), Value::String(within.clone()));
}
}
Some(Value::Object(context))
}
}
#[derive(Debug, Clone)]
pub struct RequestExecution {
pub output: String,
pub export_env: HashMap<String, String>,
pub artifact: ExecutionArtifact,
pub assertions: Vec<AssertionOutcome>,
pub reliability: ExecutionReliabilityMetadata,
pub sensitive_values: Vec<String>,
pub sensitive_export_values: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionFailureClass {
Transport,
Assertion,
Timeout,
}
impl ExecutionFailureClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Transport => "transport",
Self::Assertion => "assertion",
Self::Timeout => "timeout",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionReliabilityMetadata {
pub attempts: usize,
pub timeout: String,
pub poll_until: Option<String>,
pub poll_every: Option<String>,
pub failure_class: Option<ExecutionFailureClass>,
}
impl Default for ExecutionReliabilityMetadata {
fn default() -> Self {
Self {
attempts: 1,
timeout: DEFAULT_REQUEST_TIMEOUT.to_string(),
poll_until: None,
poll_every: None,
failure_class: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AssertionOutcome {
pub assertion: String,
pub status: AssertionStatus,
pub message: Option<String>,
pub mismatch: Option<AssertionMismatch>,
pub diff: Option<AssertionDiff>,
}
impl AssertionOutcome {
pub(crate) fn passed(assertion: impl Into<String>) -> Self {
Self {
assertion: assertion.into(),
status: AssertionStatus::Passed,
message: None,
mismatch: None,
diff: None,
}
}
pub(crate) fn failed(
assertion: impl Into<String>,
message: impl Into<String>,
mismatch: Option<AssertionMismatch>,
diff: Option<AssertionDiff>,
) -> Self {
Self {
assertion: assertion.into(),
status: AssertionStatus::Failed,
message: Some(message.into()),
mismatch,
diff,
}
}
pub(crate) fn skipped(assertion: impl Into<String>, message: impl Into<String>) -> Self {
Self {
assertion: assertion.into(),
status: AssertionStatus::Skipped,
message: Some(message.into()),
mismatch: None,
diff: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionStatus {
Passed,
Failed,
Skipped,
}
impl AssertionStatus {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Passed => "passed",
Self::Failed => "failed",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone)]
pub struct RequestExecutionError {
message: String,
assertions: Vec<AssertionOutcome>,
artifact: Option<ExecutionArtifact>,
sensitive_values: Vec<String>,
classification: ExecutionFailureClass,
}
impl RequestExecutionError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
assertions: Vec::new(),
artifact: None,
sensitive_values: Vec::new(),
classification: ExecutionFailureClass::Transport,
}
}
fn with_artifact(message: impl Into<String>, artifact: ExecutionArtifact) -> Self {
Self {
message: message.into(),
assertions: Vec::new(),
artifact: Some(artifact),
sensitive_values: Vec::new(),
classification: ExecutionFailureClass::Transport,
}
}
fn with_assertions_and_artifact(
message: impl Into<String>,
assertions: Vec<AssertionOutcome>,
artifact: ExecutionArtifact,
) -> Self {
Self {
message: message.into(),
assertions,
artifact: Some(artifact),
sensitive_values: Vec::new(),
classification: ExecutionFailureClass::Assertion,
}
}
pub(crate) fn timeout(message: impl Into<String>) -> Self {
Self {
message: message.into(),
assertions: Vec::new(),
artifact: None,
sensitive_values: Vec::new(),
classification: ExecutionFailureClass::Timeout,
}
}
fn with_sensitive_values(mut self, sensitive_values: Vec<String>) -> Self {
self.sensitive_values = sensitive_values;
self
}
pub(crate) fn into_parts(
self,
) -> (
String,
Vec<AssertionOutcome>,
Option<ExecutionArtifact>,
Vec<String>,
ExecutionFailureClass,
) {
(
self.message,
self.assertions,
self.artifact,
self.sensitive_values,
self.classification,
)
}
pub(crate) fn classification(&self) -> ExecutionFailureClass {
self.classification
}
}
impl fmt::Display for RequestExecutionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for RequestExecutionError {}
fn graphql_protocol_context_json(
operation_name: Option<&str>,
variables_json: Option<&str>,
) -> Option<Value> {
let mut context = serde_json::Map::new();
if let Some(operation_name) = operation_name {
context.insert(
"operationName".to_string(),
Value::String(operation_name.to_string()),
);
}
if let Some(variables_json) = variables_json {
match serde_json::from_str::<Value>(variables_json) {
Ok(value) => {
context.insert("variables".to_string(), value);
}
Err(_) => {
context.insert(
"variablesText".to_string(),
Value::String(variables_json.to_string()),
);
}
}
}
if context.is_empty() {
None
} else {
Some(Value::Object(context))
}
}
fn insert_json_or_text_field(
context: &mut serde_json::Map<String, Value>,
json_key: &str,
text_key: &str,
raw_json: &str,
) {
match serde_json::from_str::<Value>(raw_json) {
Ok(value) => {
context.insert(json_key.to_string(), value);
}
Err(_) => {
context.insert(text_key.to_string(), Value::String(raw_json.to_string()));
}
}
}
#[derive(Debug, Clone)]
pub struct MapIteration {
pub variables: Vec<(String, String)>,
pub label: String,
}
impl MapIteration {
pub fn suffix(&self) -> String {
if self.label.is_empty() {
String::new()
} else {
format!("[{}]", self.label)
}
}
}
impl Request {
pub fn protocol(&self) -> RequestProtocol {
self.operation.protocol()
}
pub fn http_operation(&self) -> &HttpOperation {
self.operation
.as_http()
.expect("request operation is not HTTP")
}
pub fn http_operation_mut(&mut self) -> &mut HttpOperation {
self.operation
.as_http_mut()
.expect("request operation is not HTTP")
}
pub fn graphql_operation(&self) -> Option<&GraphqlOperation> {
self.operation.as_graphql()
}
pub fn mcp_operation(&self) -> Option<&McpOperation> {
self.operation.as_mcp()
}
pub fn sse_operation(&self) -> Option<&SseOperation> {
self.operation.as_sse()
}
pub fn ws_operation(&self) -> Option<&WsOperation> {
self.operation.as_ws()
}
pub fn protocol_context_json(&self) -> Option<Value> {
let mut context = match self.operation.protocol_context_json() {
Some(Value::Object(map)) => map,
Some(other) => return Some(other),
None => serde_json::Map::new(),
};
if self.session_name.is_some()
&& matches!(
self.protocol(),
RequestProtocol::Http
| RequestProtocol::Mcp
| RequestProtocol::Sse
| RequestProtocol::Ws
)
{
if let Some(session_name) = &self.session_name {
context.insert("sessionName".to_string(), Value::String(session_name.clone()));
}
}
if let Some(auth_profile_name) = &self.auth_profile_name {
context.insert(
"authProfile".to_string(),
Value::String(auth_profile_name.clone()),
);
}
if self.reliability.timeout != DEFAULT_REQUEST_TIMEOUT || self.reliability.poll_until.is_some()
{
context.insert(
"timeout".to_string(),
Value::String(self.reliability.timeout.clone()),
);
if let Some(poll_until) = &self.reliability.poll_until {
context.insert(
"pollUntil".to_string(),
Value::String(poll_until.clone()),
);
}
if let Some(poll_every) = &self.reliability.poll_every {
context.insert(
"pollEvery".to_string(),
Value::String(poll_every.clone()),
);
}
}
if context.is_empty() {
None
} else {
Some(Value::Object(context))
}
}
pub async fn exec(
&self,
inherited_context: &HashMap<String, String>,
inherited_sensitive_values: &[String],
dependency_artifacts: &HashMap<String, ExecutionArtifact>,
sessions: &SessionRegistry,
observer: Option<&ExecutionObserver>,
) -> Result<RequestExecution, RequestExecutionError> {
executor::execute_request(
self,
inherited_context,
inherited_sensitive_values,
dependency_artifacts,
observer,
sessions,
)
.await
}
#[cfg(test)]
fn build_reqwest_request(
&self,
client: &reqwest::Client,
context_map: &HashMap<String, String>,
) -> Result<reqwest::Request, Box<dyn std::error::Error>> {
executor::build_reqwest_request(self.http_operation(), client, context_map, None)
}
pub fn as_curl(&self) -> String {
executor::render_curl(self)
}
}
fn parse_callback_assignment(
src: &str,
) -> Result<Option<(String, String)>, CallbackAssignmentError> {
let trimmed = src.trim();
let Some(index) = trimmed.find("->") else {
return Ok(None);
};
if index == 0 {
return Err(CallbackAssignmentError::new(
"callback assignment is missing a command before '->'",
));
}
let before_arrow = &trimmed[..index];
let after_arrow = &trimmed[index + 2..];
let has_surrounding_whitespace = before_arrow
.chars()
.last()
.map(|c| c.is_whitespace())
.unwrap_or(false)
&& after_arrow
.chars()
.next()
.map(|c| c.is_whitespace())
.unwrap_or(false);
if !has_surrounding_whitespace {
return Err(CallbackAssignmentError::new(
"callback assignment requires whitespace on both sides of '->'",
));
}
let command = before_arrow.trim();
if command.is_empty() {
return Err(CallbackAssignmentError::new(
"callback assignment command cannot be empty",
));
}
let remainder = after_arrow.trim();
if !remainder.starts_with('$') {
return Err(CallbackAssignmentError::new(
"callback assignment target must start with '$'",
));
}
let name = remainder.trim_start_matches('$').trim();
if name.is_empty() {
return Err(CallbackAssignmentError::new(
"callback assignment target variable name cannot be empty",
));
}
Ok(Some((command.to_string(), name.to_string())))
}
fn sanitize_callback_assignment_output(output: &str) -> String {
output
.trim_end_matches(['\n', '\r'])
.replace('\0', "")
.to_string()
}
#[derive(Debug)]
struct CallbackAssignmentError {
message: String,
}
impl CallbackAssignmentError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for CallbackAssignmentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for CallbackAssignmentError {}
#[derive(Debug)]
struct CaptureDependencyError {
dependency: String,
request: String,
}
impl std::fmt::Display for CaptureDependencyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Request '{}' references dependency '{}' in a capture but it has not executed yet.",
self.request, self.dependency
)
}
}
impl std::error::Error for CaptureDependencyError {}
fn apply_map_suffix_to_exports(
export_env: HashMap<String, String>,
iteration: Option<&MapIteration>,
) -> HashMap<String, String> {
let Some(iteration) = iteration else {
return export_env;
};
let suffix = iteration.suffix();
if suffix.is_empty() {
return export_env;
}
let reserved = ["RESPONSE", "STATUS", "STATUS_TEXT", "DESCRIPTION"];
let mut transformed: HashMap<String, String> = HashMap::with_capacity(export_env.len());
for (key, value) in export_env.into_iter() {
if reserved.iter().any(|reserved_key| reserved_key == &key) {
transformed.insert(key, value);
} else {
transformed.insert(format!("{}{}", key, suffix), value);
}
}
transformed
}
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::HashMap, path::PathBuf};
fn base_request() -> Request {
Request {
description: "Test Request".into(),
base_description: "Test Request".into(),
operation: RequestOperation::Http(HttpOperation {
method: Method::POST,
url: "https://example.com".into(),
headers: HashMap::new(),
query_params: Vec::new(),
cookies: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
}),
reliability: RequestReliabilityPolicy::default(),
session_name: None,
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
}
}
fn base_graphql_request() -> Request {
Request {
description: "GraphQL Request".into(),
base_description: "GraphQL Request".into(),
operation: RequestOperation::Graphql(GraphqlOperation {
http: HttpOperation {
method: Method::POST,
url: "https://example.com/graphql".into(),
headers: HashMap::new(),
query_params: Vec::new(),
cookies: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
operation_name: Some("GetUser".into()),
document: "query GetUser($id: ID!) { user(id: $id) { id } }".into(),
variables_json: Some(r#"{"id":"123"}"#.into()),
}),
reliability: RequestReliabilityPolicy::default(),
session_name: None,
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
}
}
fn base_mcp_request() -> Request {
Request {
description: "MCP Request".into(),
base_description: "MCP Request".into(),
operation: RequestOperation::Mcp(McpOperation {
http: HttpOperation {
method: Method::POST,
url: "https://example.com/mcp".into(),
headers: HashMap::new(),
query_params: Vec::new(),
cookies: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
call: McpCall::Initialize(McpInitialize {
protocol_version: None,
client_name: None,
client_version: None,
capabilities_json: None,
}),
}),
reliability: RequestReliabilityPolicy::default(),
session_name: Some("app".into()),
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
}
}
fn base_sse_request() -> Request {
Request {
description: "SSE Request".into(),
base_description: "SSE Request".into(),
operation: RequestOperation::Sse(SseOperation {
http: HttpOperation {
method: Method::GET,
url: "https://example.com/stream".into(),
headers: HashMap::new(),
query_params: Vec::new(),
cookies: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
action: SseAction::Receive {
within: "5s".into(),
},
}),
reliability: RequestReliabilityPolicy::default(),
session_name: Some("prices".into()),
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
}
}
fn base_ws_request() -> Request {
Request {
description: "WS Request".into(),
base_description: "WS Request".into(),
operation: RequestOperation::Ws(WsOperation {
http: HttpOperation {
method: Method::GET,
url: "wss://example.com/ws".into(),
headers: HashMap::new(),
query_params: Vec::new(),
cookies: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
action: WsAction::Send {
kind: WsSendKind::Json,
payload: r#"{"type":"ping"}"#.into(),
},
}),
reliability: RequestReliabilityPolicy::default(),
session_name: Some("chat".into()),
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
}
}
#[test]
fn callback_assignment_parses_with_whitespace() {
let (command, variable) = parse_callback_assignment("echo foo -> $BAR")
.unwrap()
.expect("expected assignment");
assert_eq!(command, "echo foo");
assert_eq!(variable, "BAR");
}
#[test]
fn callback_assignment_requires_whitespace() {
let err = parse_callback_assignment("echo foo->$BAR").unwrap_err();
assert!(err.to_string().contains("whitespace on both sides of '->'"));
}
#[test]
fn callback_assignment_requires_dollar_prefix() {
let err = parse_callback_assignment("echo foo -> BAR").unwrap_err();
assert!(err
.to_string()
.contains("callback assignment target must start with '$'"));
}
#[test]
fn build_request_allows_manual_content_type_override_for_multipart() {
let mut request = base_request();
request
.http_operation_mut()
.headers
.insert("Content-Type".into(), "application/octet-stream".into());
request
.http_operation_mut()
.form_data
.insert("name".into(), FormDataType::Text("hen".into()));
let client = reqwest::Client::new();
let built = request
.build_reqwest_request(&client, &HashMap::new())
.expect("request should build");
let content_type = built
.headers()
.get(reqwest::header::CONTENT_TYPE)
.expect("content-type header should exist")
.to_str()
.expect("content-type should be valid utf-8");
assert_eq!(content_type, "application/octet-stream");
assert_eq!(
built
.headers()
.get_all(reqwest::header::CONTENT_TYPE)
.iter()
.count(),
1
);
}
#[test]
fn build_request_uses_curl_style_multipart_boundary_without_override() {
let mut request = base_request();
request
.http_operation_mut()
.form_data
.insert("name".into(), FormDataType::Text("hen".into()));
let client = reqwest::Client::new();
let built = request
.build_reqwest_request(&client, &HashMap::new())
.expect("request should build");
let content_type = built
.headers()
.get(reqwest::header::CONTENT_TYPE)
.expect("content-type header should exist")
.to_str()
.expect("content-type should be valid utf-8");
let boundary = content_type
.strip_prefix("multipart/form-data; boundary=")
.expect("multipart boundary should be present");
let body = built
.body()
.and_then(|body| body.as_bytes())
.expect("multipart body should be buffered");
let body = String::from_utf8(body.to_vec()).expect("text-only multipart should be utf-8");
assert!(boundary.starts_with("------------------------"));
assert_eq!(boundary.len(), 56);
assert!(body.starts_with(&format!("--{}\r\n", boundary)));
assert!(body.contains("Content-Disposition: form-data; name=\"name\"\r\n\r\nhen\r\n"));
assert!(body.ends_with(&format!("--{}--\r\n", boundary)));
}
#[test]
fn as_curl_omits_auto_content_type_when_manual_override_exists() {
let mut request = base_request();
request.http_operation_mut().body = Some("{}".into());
request.http_operation_mut().body_content_type = Some("application/json".into());
request
.http_operation_mut()
.headers
.insert("Content-Type".into(), "application/octet-stream".into());
let curl = request.as_curl();
assert!(curl.contains("-H 'Content-Type: application/octet-stream'"));
assert!(!curl.contains("-H 'Content-Type: application/json'"));
}
#[test]
fn build_request_returns_invalid_header_name_error() {
let mut request = base_request();
request
.http_operation_mut()
.headers
.insert("bad header".into(), "value".into());
let client = reqwest::Client::new();
let err = request
.build_reqwest_request(&client, &HashMap::new())
.expect_err("request should fail");
assert!(err.to_string().contains("Invalid header name"));
}
#[test]
fn build_request_emits_cookie_header_from_structured_cookies() {
let mut request = base_request();
request
.http_operation_mut()
.cookies
.insert("session".into(), "abc123".into());
let client = reqwest::Client::new();
let built = request
.build_reqwest_request(&client, &HashMap::new())
.expect("request should build");
let cookie = built
.headers()
.get(reqwest::header::COOKIE)
.expect("cookie header should exist")
.to_str()
.expect("cookie header should be valid utf-8");
assert_eq!(cookie, "session=abc123");
}
#[test]
fn build_request_preserves_duplicate_query_parameters() {
let mut request = base_request();
request
.http_operation_mut()
.query_params
.push(("tag".into(), "foo".into()));
request
.http_operation_mut()
.query_params
.push(("tag".into(), "bar".into()));
request
.http_operation_mut()
.query_params
.push(("page".into(), "1".into()));
let client = reqwest::Client::new();
let built = request
.build_reqwest_request(&client, &HashMap::new())
.expect("request should build");
assert_eq!(
built.url().query(),
Some("tag=foo&tag=bar&page=1")
);
let curl = request.as_curl();
assert!(curl.contains("?tag=foo&tag=bar&page=1"));
}
#[test]
fn request_protocol_defaults_to_http() {
let request = base_request();
assert_eq!(request.protocol(), RequestProtocol::Http);
assert_eq!(request.protocol().as_str(), "http");
}
#[test]
fn graphql_requests_report_graphql_protocol() {
let request = base_graphql_request();
assert_eq!(request.protocol(), RequestProtocol::Graphql);
assert_eq!(request.protocol().as_str(), "graphql");
assert_eq!(
request
.graphql_operation()
.expect("graphql operation should exist")
.operation_name
.as_deref(),
Some("GetUser")
);
}
#[test]
fn graphql_as_curl_serializes_http_json_payload() {
let request = base_graphql_request();
let curl = request.as_curl();
assert!(curl.contains("https://example.com/graphql"));
assert!(curl.contains("Content-Type: application/json"));
assert!(curl.contains("application/graphql-response+json, application/json"));
assert!(curl.contains("operationName"));
assert!(curl.contains("GetUser"));
assert!(curl.contains("variables"));
assert!(curl.contains("query GetUser($id: ID!)"));
}
#[test]
fn mcp_requests_report_mcp_protocol() {
let request = base_mcp_request();
assert_eq!(request.protocol(), RequestProtocol::Mcp);
assert_eq!(request.protocol().as_str(), "mcp");
assert_eq!(
request
.mcp_operation()
.expect("mcp operation should exist")
.call
.method_name(),
"initialize"
);
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")["sessionName"],
"app"
);
}
#[test]
fn mcp_as_curl_serializes_jsonrpc_payload() {
let request = base_mcp_request();
let curl = request.as_curl();
assert!(curl.contains("https://example.com/mcp"));
assert!(curl.contains("Accept: application/json, text/event-stream"));
assert!(curl.contains("Content-Type: application/json"));
assert!(curl.contains("\"method\":\"initialize\""));
assert!(curl.contains("\"protocolVersion\":\"2025-11-25\""));
assert!(curl.contains("\"name\":\"hen\""));
assert!(curl.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn sse_requests_report_sse_protocol() {
let request = base_sse_request();
assert_eq!(request.protocol(), RequestProtocol::Sse);
assert_eq!(request.protocol().as_str(), "sse");
let sse = request
.sse_operation()
.expect("sse operation should exist");
assert!(matches!(sse.action, SseAction::Receive { .. }));
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")
["sessionName"],
"prices"
);
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")["action"],
"receive"
);
}
#[test]
fn ws_requests_report_ws_protocol() {
let request = base_ws_request();
assert_eq!(request.protocol(), RequestProtocol::Ws);
assert_eq!(request.protocol().as_str(), "ws");
let ws = request.ws_operation().expect("ws operation should exist");
assert!(matches!(ws.action, WsAction::Send { .. }));
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")["sessionName"],
"chat"
);
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")["action"],
"send"
);
assert_eq!(
request.protocol_context_json().expect("protocol context should exist")["kind"],
"json"
);
}
#[test]
fn ws_exchange_requests_report_ws_protocol() {
let mut request = base_ws_request();
request.operation = RequestOperation::Ws(WsOperation {
http: request.http_operation().clone(),
action: WsAction::Exchange {
kind: WsSendKind::Text,
payload: "hello".into(),
within: "1s".into(),
},
});
assert_eq!(request.protocol_context_json().expect("protocol context should exist")["action"], "exchange");
assert_eq!(request.protocol_context_json().expect("protocol context should exist")["kind"], "text");
assert_eq!(request.protocol_context_json().expect("protocol context should exist")["within"], "1s");
}
}