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 raw_body: String,
pub asserts: Vec<AssertOutcome>,
pub captures: Vec<(String, String)>,
pub duration_ms: u64,
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, raw_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 bytes = r.uncompress_body().unwrap_or_else(|_| r.body.clone());
let raw = String::from_utf8_lossy(&bytes).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_else(|_| raw.clone());
(r.status as u16, hdrs, body, raw)
}
None => (0, Vec::new(), String::new(), String::new()),
};
let mut asserts = Vec::new();
for a in &e.asserts {
if let AssertResult::ImplicitStatus {
actual, expected, ..
} = a
{
let failed = a.to_runner_error().is_some();
asserts.push(AssertOutcome {
expr: format!("status == {expected}"),
passed: !failed,
detail: if failed {
format!("got {actual}")
} else {
String::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 status_mismatch = e.asserts.iter().find_map(|a| match a {
AssertResult::ImplicitStatus {
actual, expected, ..
} if a.to_runner_error().is_some() => Some((*actual, *expected)),
_ => None,
});
let entry_error = if let Some((actual, expected)) = status_mismatch {
let reason = reason(actual as u16);
let actual_txt = if reason.is_empty() {
format!("{actual}")
} else {
format!("{actual} {reason}")
};
Some(format!(
"Expected status {expected} but got {actual_txt} ({method} {url})"
))
} else {
e.errors.first().map(|er| render_error(er, lines))
};
(
EntryOutcome {
method,
url,
status,
status_text: reason(status).to_string(),
headers,
body,
raw_body,
asserts,
captures,
duration_ms: e.transfer_duration.as_millis() as u64,
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::*;
fn one_shot_server(status: u16, reason: &str) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let reason = reason.to_string();
std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let body = "{\"ok\":true}";
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
}
});
port
}
#[test]
fn status_line_appears_as_a_passing_assert() {
let port = one_shot_server(200, "OK");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(e.ok, "entry should pass, error: {:?}", e.error);
let status_assert = e
.asserts
.iter()
.find(|a| a.expr == "status == 200")
.expect("a `status == 200` assert row");
assert!(status_assert.passed);
}
fn one_shot_gzip_server(gzip_body: &'static [u8]) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nConnection: close\r\n\r\n";
let _ = sock.write_all(head);
let _ = sock.write_all(gzip_body);
let _ = sock.flush();
}
});
port
}
#[test]
fn gzip_response_body_is_decompressed() {
static GZIP_OK: &[u8] = &[
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf,
0x56, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b,
0x00, 0x00, 0x00,
];
let port = one_shot_gzip_server(GZIP_OK);
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(e.ok, "entry should pass, error: {:?}", e.error);
assert_eq!(e.raw_body, "{\"ok\":true}");
assert!(
e.body.contains("\"ok\": true"),
"body should be decompressed pretty JSON, got: {:?}",
e.body
);
}
#[test]
fn failed_status_assertion_has_a_clear_message() {
let port = one_shot_server(404, "Not Found");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(!e.ok);
let status_assert = e
.asserts
.iter()
.find(|a| a.expr == "status == 200")
.expect("a `status == 200` assert row");
assert!(!status_assert.passed);
assert!(
status_assert.detail.contains("404"),
"detail should show the actual status, got: {}",
status_assert.detail
);
let msg = e.error.as_deref().unwrap_or_default();
assert!(
msg.contains("Expected status 200") && msg.contains("got 404"),
"message should state expected vs actual, got: {msg}"
);
assert!(
!msg.contains("Assert status code"),
"message should not be the terse runner default, got: {msg}"
);
}
#[test]
fn wildcard_status_line_produces_no_status_assert() {
let port = one_shot_server(200, "OK");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP *\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(
!e.asserts.iter().any(|a| a.expr.starts_with("status ==")),
"HTTP * should not synthesize a status assert"
);
}
#[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();
}
}