use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::PathBuf;
use http::Method;
use crate::parser::context;
use super::{Assertion, FormDataType, MapIteration, Request, ResponseCapture};
pub const MAX_ARRAY_VARIABLES_PER_REQUEST: usize = 2;
pub const MAX_MAP_COMBINATIONS: usize = 128;
#[derive(Debug, Clone, Default)]
pub struct VariableStore {
scalars: HashMap<String, String>,
arrays: HashMap<String, Vec<String>>,
}
impl VariableStore {
pub fn new() -> Self {
Self::default()
}
pub fn scalars(&self) -> &HashMap<String, String> {
&self.scalars
}
pub fn set_scalar(&mut self, key: String, value: String) {
self.arrays.remove(&key);
self.scalars.insert(key, value);
}
pub fn set_array(&mut self, key: String, values: Vec<String>) {
self.scalars.remove(&key);
self.arrays.insert(key, values);
}
pub fn contains_array(&self, key: &str) -> bool {
self.arrays.contains_key(key)
}
pub fn get_array(&self, key: &str) -> Option<&[String]> {
self.arrays.get(key).map(|vals| vals.as_slice())
}
pub fn clone_scalars(&self) -> HashMap<String, String> {
self.scalars.clone()
}
}
#[derive(Debug, Clone)]
pub struct RequestTemplate {
pub group_key: String,
pub description_template: String,
pub method: Method,
pub url: String,
pub headers: HashMap<String, String>,
pub query_params: HashMap<String, String>,
pub form_text: HashMap<String, String>,
pub form_files: HashMap<String, String>,
pub body: Option<String>,
pub body_content_type: Option<String>,
pub callback_src: Vec<String>,
pub response_captures: Vec<ResponseCapture>,
pub assertions: Vec<Assertion>,
pub declared_dependencies: Vec<String>,
pub variable_store: VariableStore,
pub working_dir: PathBuf,
}
#[derive(Debug)]
pub enum TemplateError {
TooManyArrayVariables {
request: String,
limit: usize,
found: usize,
},
MissingArrayValues {
request: String,
variable: String,
},
EmptyArrayValues {
request: String,
variable: String,
},
TooManyCombinations {
request: String,
limit: usize,
count: usize,
},
InvalidArrayValue {
request: String,
variable: String,
value: String,
},
DependencyOnMappedRequest {
request: String,
dependency: String,
},
UnknownDependency {
request: String,
dependency: String,
},
}
impl fmt::Display for TemplateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TemplateError::TooManyArrayVariables { request, limit, found } => {
write!(
f,
"Request '{}' references {} array variables but the limit is {}.",
request, found, limit
)
}
TemplateError::MissingArrayValues { request, variable } => {
write!(
f,
"Request '{}' references array variable '{}' which is not defined as an array.",
request, variable
)
}
TemplateError::EmptyArrayValues { request, variable } => {
write!(
f,
"Request '{}' references array variable '{}' but it contains no values.",
request, variable
)
}
TemplateError::TooManyCombinations { request, limit, count } => {
write!(
f,
"Request '{}' expands into {} combinations which exceeds the limit of {}.",
request, count, limit
)
}
TemplateError::InvalidArrayValue {
request,
variable,
value,
} => {
write!(
f,
"Request '{}' defines array variable '{}' with unsupported value '{}'.",
request, variable, value
)
}
TemplateError::DependencyOnMappedRequest { request, dependency } => {
write!(
f,
"Request '{}' depends on '{}' which expands into multiple iterations. Dependencies on mapped requests are not supported yet.",
request, dependency
)
}
TemplateError::UnknownDependency { request, dependency } => {
write!(
f,
"Request '{}' declares unknown dependency '{}'.",
request, dependency
)
}
}
}
}
impl std::error::Error for TemplateError {}
pub fn expand_templates(templates: Vec<RequestTemplate>) -> Result<Vec<Request>, TemplateError> {
let mut requests: Vec<Request> = Vec::new();
for template in templates {
let mut expanded = expand_template(&template)?;
requests.append(&mut expanded);
}
resolve_dependencies(&mut requests)?;
Ok(requests)
}
fn expand_template(template: &RequestTemplate) -> Result<Vec<Request>, TemplateError> {
let array_vars = detect_array_variables(template);
if array_vars.is_empty() {
let request = build_request(template, &[], &[])?;
return Ok(vec![request]);
}
if array_vars.len() > MAX_ARRAY_VARIABLES_PER_REQUEST {
return Err(TemplateError::TooManyArrayVariables {
request: template.group_key.clone(),
limit: MAX_ARRAY_VARIABLES_PER_REQUEST,
found: array_vars.len(),
});
}
let mut arrays: Vec<(String, Vec<String>)> = Vec::new();
for var in &array_vars {
match template.variable_store.get_array(var) {
Some(values) => {
if values.is_empty() {
return Err(TemplateError::EmptyArrayValues {
request: template.group_key.clone(),
variable: var.clone(),
});
}
arrays.push((var.clone(), values.to_vec()));
}
None => {
return Err(TemplateError::MissingArrayValues {
request: template.group_key.clone(),
variable: var.clone(),
});
}
}
}
arrays.sort_by(|a, b| a.0.cmp(&b.0));
let combination_count: usize = arrays
.iter()
.map(|(_, values)| values.len())
.product();
if combination_count > MAX_MAP_COMBINATIONS {
return Err(TemplateError::TooManyCombinations {
request: template.group_key.clone(),
limit: MAX_MAP_COMBINATIONS,
count: combination_count,
});
}
let mut requests = Vec::with_capacity(combination_count.max(1));
let mut indices = vec![0usize; arrays.len()];
loop {
let request = build_request(template, &arrays, &indices)?;
requests.push(request);
if arrays.is_empty() {
break;
}
let mut pos = arrays.len();
let mut advanced = false;
while pos > 0 {
pos -= 1;
if indices[pos] + 1 < arrays[pos].1.len() {
indices[pos] += 1;
for idx in indices.iter_mut().skip(pos + 1) {
*idx = 0;
}
advanced = true;
break;
}
}
if !advanced {
break;
}
}
Ok(requests)
}
fn build_request(
template: &RequestTemplate,
arrays: &[(String, Vec<String>)],
indices: &[usize],
) -> Result<Request, TemplateError> {
let (iteration_context, map_iteration) = build_iteration_context(template, arrays, indices)?;
let resolved_description = context::resolve_with_context(
template.description_template.as_str(),
&iteration_context,
);
let base_description = template.group_key.clone();
let description = match &map_iteration {
Some(iter) => {
let suffix = iter.suffix();
if resolved_description.trim().is_empty() {
format!("{} {}", base_description, suffix).trim().to_string()
} else {
format!("{} {}", resolved_description, suffix).trim().to_string()
}
}
None => {
if resolved_description.trim().is_empty() {
base_description.clone()
} else {
resolved_description
}
}
};
let resolved_url = context::resolve_with_context(template.url.as_str(), &iteration_context);
let mut headers = HashMap::new();
for (key, value) in &template.headers {
headers.insert(
key.clone(),
context::resolve_with_context(value.as_str(), &iteration_context),
);
}
let mut query_params = HashMap::new();
for (key, value) in &template.query_params {
query_params.insert(
key.clone(),
context::resolve_with_context(value.as_str(), &iteration_context),
);
}
let mut form_data = HashMap::new();
for (key, value) in &template.form_text {
form_data.insert(
key.clone(),
FormDataType::Text(context::resolve_with_context(
value.as_str(),
&iteration_context,
)),
);
}
for (key, value) in &template.form_files {
let resolved_path = context::resolve_with_context(value.as_str(), &iteration_context);
let path = PathBuf::from(&resolved_path);
let absolute = if path.is_absolute() {
path
} else {
template.working_dir.join(path)
};
form_data.insert(key.clone(), FormDataType::File(absolute));
}
let body = template
.body
.as_ref()
.map(|body| context::resolve_with_context(body.as_str(), &iteration_context));
let body_content_type = template.body_content_type.as_ref().map(|ctype| {
context::resolve_with_context(ctype.as_str(), &iteration_context)
});
let callback_src = template
.callback_src
.iter()
.map(|src| context::resolve_with_context(src.as_str(), &iteration_context))
.collect();
Ok(Request {
description,
base_description,
method: template.method.clone(),
url: resolved_url,
headers,
query_params,
form_data,
body,
body_content_type,
callback_src,
response_captures: template.response_captures.clone(),
assertions: template.assertions.clone(),
declared_dependencies: template.declared_dependencies.clone(),
dependencies: template.declared_dependencies.clone(),
context: iteration_context,
working_dir: template.working_dir.clone(),
map_iteration,
})
}
fn build_iteration_context(
template: &RequestTemplate,
arrays: &[(String, Vec<String>)] ,
indices: &[usize],
) -> Result<(HashMap<String, String>, Option<MapIteration>), TemplateError> {
let mut context = template.variable_store.clone_scalars();
let mut iteration_pairs: Vec<(String, String)> = Vec::new();
for (idx, (name, values)) in arrays.iter().enumerate() {
let selected = values
.get(indices[idx])
.cloned()
.ok_or_else(|| TemplateError::MissingArrayValues {
request: template.group_key.clone(),
variable: name.clone(),
})?;
let resolved = context::resolve_with_context(selected.as_str(), &context);
validate_iteration_value(template, name, &resolved)?;
context.insert(name.clone(), resolved.clone());
iteration_pairs.push((name.clone(), resolved));
}
let scalar_keys: Vec<String> = template.variable_store.scalars().keys().cloned().collect();
for key in scalar_keys {
if let Some(value) = template.variable_store.scalars().get(&key) {
let resolved = context::resolve_with_context(value.as_str(), &context);
context.insert(key.clone(), resolved);
}
}
let map_iteration = if iteration_pairs.is_empty() {
None
} else {
let label = iteration_pairs
.iter()
.map(|(name, value)| format!("{}={}", name, value))
.collect::<Vec<String>>()
.join(", ");
Some(MapIteration {
variables: iteration_pairs,
label,
})
};
Ok((context, map_iteration))
}
fn validate_iteration_value(
template: &RequestTemplate,
variable: &str,
value: &str,
) -> Result<(), TemplateError> {
if value.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: template.group_key.clone(),
variable: variable.to_string(),
value: value.to_string(),
});
}
if value.chars().any(|ch| ch.is_whitespace() || matches!(ch, '[' | ']')) {
return Err(TemplateError::InvalidArrayValue {
request: template.group_key.clone(),
variable: variable.to_string(),
value: value.to_string(),
});
}
Ok(())
}
fn detect_array_variables(template: &RequestTemplate) -> Vec<String> {
let mut found: HashSet<String> = HashSet::new();
collect_from_string(&mut found, &template.variable_store, template.url.as_str());
collect_from_string(
&mut found,
&template.variable_store,
template.description_template.as_str(),
);
for value in template.headers.values() {
collect_from_string(&mut found, &template.variable_store, value.as_str());
}
for value in template.query_params.values() {
collect_from_string(&mut found, &template.variable_store, value.as_str());
}
for value in template.form_text.values() {
collect_from_string(&mut found, &template.variable_store, value.as_str());
}
for value in template.form_files.values() {
collect_from_string(&mut found, &template.variable_store, value.as_str());
}
if let Some(body) = &template.body {
collect_from_string(&mut found, &template.variable_store, body.as_str());
}
if let Some(content_type) = &template.body_content_type {
collect_from_string(&mut found, &template.variable_store, content_type.as_str());
}
for src in &template.callback_src {
collect_from_string(&mut found, &template.variable_store, src.as_str());
}
for capture in &template.response_captures {
collect_from_string(&mut found, &template.variable_store, capture.raw_path.as_str());
if let Some(default) = &capture.default {
collect_from_string(&mut found, &template.variable_store, default.as_str());
}
}
for assertion in &template.assertions {
collect_from_string(&mut found, &template.variable_store, assertion.raw.as_str());
}
let mut variables: Vec<String> = found.into_iter().collect();
variables.sort();
variables
}
fn collect_from_string(
acc: &mut HashSet<String>,
store: &VariableStore,
value: &str,
) {
for placeholder in context::extract_placeholders(value) {
if store.contains_array(&placeholder) {
acc.insert(placeholder);
}
}
}
fn resolve_dependencies(requests: &mut [Request]) -> Result<(), TemplateError> {
let mut lookup: HashMap<String, Vec<String>> = HashMap::new();
for request in requests.iter() {
lookup
.entry(request.base_description.clone())
.or_default()
.push(request.description.clone());
}
for request in requests.iter_mut() {
let mut resolved = Vec::with_capacity(request.declared_dependencies.len());
for dependency in &request.declared_dependencies {
match lookup.get(dependency) {
Some(actual) if actual.len() == 1 => resolved.push(actual[0].clone()),
Some(_) => {
return Err(TemplateError::DependencyOnMappedRequest {
request: request.base_description.clone(),
dependency: dependency.clone(),
});
}
None => {
return Err(TemplateError::UnknownDependency {
request: request.base_description.clone(),
dependency: dependency.clone(),
});
}
}
}
request.dependencies = resolved;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use http::Method;
fn make_store() -> VariableStore {
let mut store = VariableStore::new();
store.set_scalar("API".to_string(), "https://example.com".to_string());
store
}
fn sorted_descriptions(requests: &[Request]) -> Vec<String> {
let mut names: Vec<String> = requests.iter().map(|req| req.description.clone()).collect();
names.sort();
names
}
#[test]
fn expands_map_requests_for_array_variables() {
let mut store = make_store();
store.set_array("USER".to_string(), vec!["foo".to_string(), "bar".to_string()]);
let template = RequestTemplate {
group_key: "Fetch user".to_string(),
description_template: "Fetch user".to_string(),
method: Method::GET,
url: "{{ API }}/users/{{ USER }}".to_string(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_text: HashMap::new(),
form_files: HashMap::new(),
body: None,
body_content_type: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
variable_store: store,
working_dir: PathBuf::new(),
};
let requests = expand_templates(vec![template]).expect("expansion should succeed");
assert_eq!(requests.len(), 2);
let names = sorted_descriptions(&requests);
assert!(names.iter().any(|name| name.contains("USER=foo")));
assert!(names.iter().any(|name| name.contains("USER=bar")));
for request in &requests {
assert!(request.url.ends_with("/users/foo") || request.url.ends_with("/users/bar"));
let iter = request
.map_iteration
.as_ref()
.expect("map iteration should exist");
assert_eq!(iter.variables.len(), 1);
assert_eq!(iter.variables[0].0, "USER");
assert!(iter.variables[0].1 == "foo" || iter.variables[0].1 == "bar");
assert!(request.context.get("USER").is_some());
}
}
#[test]
fn dependency_on_mapped_request_is_rejected() {
let mut store = make_store();
store.set_array("ITEM".to_string(), vec!["a".to_string(), "b".to_string()]);
let map_template = RequestTemplate {
group_key: "Map Request".to_string(),
description_template: "Map Request".to_string(),
method: Method::GET,
url: "/items/{{ ITEM }}".to_string(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_text: HashMap::new(),
form_files: HashMap::new(),
body: None,
body_content_type: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
variable_store: store,
working_dir: PathBuf::new(),
};
let dependent_template = RequestTemplate {
group_key: "Dependent".to_string(),
description_template: "Dependent".to_string(),
method: Method::GET,
url: "/dependent".to_string(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_text: HashMap::new(),
form_files: HashMap::new(),
body: None,
body_content_type: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec!["Map Request".to_string()],
variable_store: VariableStore::new(),
working_dir: PathBuf::new(),
};
let err = expand_templates(vec![map_template, dependent_template])
.expect_err("dependency on mapped request should fail");
match err {
TemplateError::DependencyOnMappedRequest { dependency, .. } => {
assert_eq!(dependency, "Map Request");
}
other => panic!("unexpected error: {other:?}"),
}
}
}