use std::collections::HashMap;
use std::path::Path;
use hurl::runner::{
self, AssertResult, EntryResult, RunnerError, RunnerOptionsBuilder, Value, VariableSet,
};
use hurl::util::logger::{Logger, LoggerOptionsBuilder};
use hurl::util::path::ContextDir;
use hurl::util::term::{Stderr, Stdout, WriteMode};
use hurl_core::error::DisplaySourceError;
use hurl_core::parser::parse_hurl_file;
use serde_json::Value as JsonValue;
#[derive(Debug, Clone)]
pub struct AssertOutcome {
pub expr: String,
pub passed: bool,
pub detail: String,
}
pub struct EntryOutcome {
pub method: String,
pub url: String,
pub status: u16,
pub status_text: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub asserts: Vec<AssertOutcome>,
pub captures: Vec<(String, String)>,
pub ok: bool,
pub error: Option<String>,
}
pub struct RunOutput {
pub entries: Vec<EntryOutcome>,
pub error: Option<String>,
}
fn context_dir(file_root: Option<&Path>) -> ContextDir {
let current_dir = std::env::current_dir().unwrap_or_default();
let file_root = file_root.unwrap_or(¤t_dir);
ContextDir::new(¤t_dir, file_root)
}
pub fn run_hurl(
content: &str,
vars: &HashMap<String, String>,
file_root: Option<&Path>,
) -> RunOutput {
let hurl_file = match parse_hurl_file(content) {
Ok(h) => h,
Err(e) => {
return RunOutput {
entries: vec![],
error: Some(format!("Parse error (line {}): {:?}", e.pos.line, e.kind)),
};
}
};
let runner_opts = RunnerOptionsBuilder::new()
.continue_on_error(true)
.context_dir(&context_dir(file_root))
.build();
let logger_opts = LoggerOptionsBuilder::new().build();
let mut variables = VariableSet::new();
for (k, v) in vars {
variables.insert(k.clone(), Value::String(v.clone()));
}
let secrets = variables.secrets();
let mut stdout = Stdout::new(WriteMode::Buffered);
let mut logger = Logger::new(&logger_opts, Stderr::new(WriteMode::Buffered), &secrets);
let result = runner::run_entries(
&hurl_file.entries,
content,
None,
&runner_opts,
&variables,
&mut stdout,
None,
&mut logger,
);
let lines: Vec<&str> = content.lines().collect();
let mut error: Option<String> = None;
let mut entries = Vec::new();
for e in &result.entries {
let (outcome, entry_error) = map_entry_result(e, &lines);
if error.is_none() {
error = entry_error;
}
entries.push(outcome);
}
RunOutput { entries, error }
}
pub fn run_hurl_streaming(
content: &str,
vars: &HashMap<String, String>,
file_root: Option<&Path>,
mut on_entry: impl FnMut(&EntryOutcome),
) -> RunOutput {
let hurl_file = match parse_hurl_file(content) {
Ok(h) => h,
Err(e) => {
return RunOutput {
entries: vec![],
error: Some(format!("Parse error (line {}): {:?}", e.pos.line, e.kind)),
};
}
};
let ctx_dir = context_dir(file_root);
let logger_opts = LoggerOptionsBuilder::new().build();
let mut variables = VariableSet::new();
for (k, v) in vars {
variables.insert(k.clone(), Value::String(v.clone()));
}
let lines: Vec<&str> = content.lines().collect();
let mut error: Option<String> = None;
let mut entries = Vec::new();
let total = hurl_file.entries.len();
for i in 1..=total {
let runner_opts = RunnerOptionsBuilder::new()
.continue_on_error(true)
.from_entry(Some(i))
.to_entry(Some(i))
.context_dir(&ctx_dir)
.build();
let secrets = variables.secrets();
let mut stdout = Stdout::new(WriteMode::Buffered);
let mut logger = Logger::new(&logger_opts, Stderr::new(WriteMode::Buffered), &secrets);
let result = runner::run_entries(
&hurl_file.entries,
content,
None,
&runner_opts,
&variables,
&mut stdout,
None,
&mut logger,
);
variables = result.variables;
for e in &result.entries {
let (outcome, entry_error) = map_entry_result(e, &lines);
if error.is_none() {
error = entry_error;
}
on_entry(&outcome);
entries.push(outcome);
}
}
RunOutput { entries, error }
}
fn map_entry_result(e: &EntryResult, lines: &[&str]) -> (EntryOutcome, Option<String>) {
let (method, url) = e
.calls
.last()
.map(|c| (c.request.method.clone(), c.request.url.to_string()))
.unwrap_or_default();
let (status, headers, body) = match e.calls.last() {
Some(call) => {
let r = &call.response;
let hdrs = r
.headers
.iter()
.map(|h| (h.name.clone(), h.value.clone()))
.collect();
let raw = String::from_utf8_lossy(&r.body).to_string();
let body = serde_json::from_str::<JsonValue>(&raw)
.map(|v| serde_json::to_string_pretty(&v).unwrap_or_else(|_| raw.clone()))
.unwrap_or(raw);
(r.status as u16, hdrs, body)
}
None => (0, Vec::new(), String::new()),
};
let mut asserts = Vec::new();
for a in &e.asserts {
if !matches!(a, AssertResult::Explicit { .. }) {
continue;
}
let err = a.to_runner_error();
let expr = lines
.get(a.line().saturating_sub(1))
.map(|l| l.trim().to_string())
.unwrap_or_default();
let detail = err.as_ref().map(assert_detail).unwrap_or_default();
asserts.push(AssertOutcome {
expr,
passed: err.is_none(),
detail,
});
}
let captures = e
.captures
.iter()
.map(|c| (c.name.clone(), c.value.to_string()))
.collect();
let entry_error = e.errors.first().map(|er| render_error(er, lines));
(
EntryOutcome {
method,
url,
status,
status_text: reason(status).to_string(),
headers,
body,
asserts,
captures,
ok: e.errors.is_empty(),
error: entry_error.clone(),
},
entry_error,
)
}
fn assert_detail(e: &RunnerError) -> String {
e.description()
}
fn render_error(e: &RunnerError, lines: &[&str]) -> String {
let desc = e.description();
let line = e.source_info().start.line;
match lines.get(line.saturating_sub(1)) {
Some(l) if !l.trim().is_empty() => format!("{desc}: {}", l.trim()),
_ => desc,
}
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_form_file_path_is_authorized_against_the_collection_directory() {
let dir = std::env::temp_dir().join(format!("paperboy_run_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("avatar.png"), b"fake-png").unwrap();
let content = "POST http://192.0.2.1/upload\n[Multipart]\navatar: file,avatar.png;\n";
let out = run_hurl(content, &HashMap::new(), Some(dir.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"a form file relative to the collection directory must be authorized, got: {msg}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_file_root_falls_back_to_the_process_current_directory() {
let cwd = std::env::current_dir().unwrap();
let unique = format!("paperboy_run_test_cwd_{}.bin", uuid::Uuid::new_v4());
let file_path = cwd.join(&unique);
std::fs::write(&file_path, b"fake").unwrap();
let content = format!("POST http://192.0.2.1/upload\n[Multipart]\nf: file,{unique};\n");
let out = run_hurl(&content, &HashMap::new(), None);
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"a file in the process's current directory must be authorized when no file_root is given, got: {msg}"
);
std::fs::remove_file(&file_path).ok();
}
#[test]
fn form_file_path_outside_the_file_root_is_still_rejected() {
let root =
std::env::temp_dir().join(format!("paperboy_run_test_root_{}", uuid::Uuid::new_v4()));
let outside = std::env::temp_dir().join(format!(
"paperboy_run_test_outside_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret.bin"), b"fake").unwrap();
let content = "POST http://192.0.2.1/upload\n[Multipart]\nf: file,../secret.bin;\n";
let out = run_hurl(content, &HashMap::new(), Some(root.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
msg.to_ascii_lowercase().contains("unauthorized"),
"a file outside file_root must still be rejected, got: {msg}"
);
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&outside).ok();
}
#[test]
fn staging_authorizes_a_form_file_that_would_otherwise_be_rejected() {
use crate::hurl::entry::{FormField, FormFieldKind, HurlEntry};
use crate::hurl::stage_out_of_scope_form_files;
let root =
std::env::temp_dir().join(format!("paperboy_stage_run_root_{}", uuid::Uuid::new_v4()));
let outside = std::env::temp_dir().join(format!(
"paperboy_stage_run_outside_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let outside_file = outside.join("secret.bin");
std::fs::write(&outside_file, b"fake").unwrap();
let mut entries = vec![HurlEntry {
method: "POST".into(),
url: "http://192.0.2.1/upload".into(),
form_fields: vec![FormField {
key: "f".into(),
value: outside_file.to_string_lossy().into_owned(),
kind: FormFieldKind::File,
..Default::default()
}],
..Default::default()
}];
let staged = stage_out_of_scope_form_files(&mut entries, Some(root.as_path())).unwrap();
assert!(
staged.is_some(),
"an out-of-scope file must trigger staging"
);
let staged_dir = staged.unwrap();
let content = entries[0].to_hurl();
let out = run_hurl(&content, &HashMap::new(), Some(staged_dir.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"the staged file must be authorized against the staging directory, got: {msg}"
);
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&outside).ok();
std::fs::remove_dir_all(&staged_dir).ok();
}
}