use serde::{Deserialize, Serialize};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FormFieldKind {
#[default]
Text,
File,
Base64File,
}
impl FormFieldKind {
pub fn is_multipart(&self) -> bool {
matches!(self, FormFieldKind::Base64File | FormFieldKind::File)
}
}
pub(crate) const BASE64_FILE_CT_MARKER: &str = "x-paperboy-base64;";
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FormField {
pub key: String,
pub value: String,
pub kind: FormFieldKind,
pub content_type: Option<String>,
#[serde(default)]
pub base64_prefix: Option<String>,
}
fn escape_form_file_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for c in path.chars() {
match c {
' ' | '#' | ';' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
_ => out.push(c),
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunStatus {
#[default]
NotRun,
Running,
Passed,
Failed,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HurlEntry {
pub title: String,
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub basic_auth: Option<(String, String)>,
#[serde(default)]
pub form_fields: Vec<FormField>,
#[serde(default)]
pub is_multipart: bool,
pub query_params: Vec<(String, String)>,
#[serde(default)]
pub cookies: Vec<(String, String)>,
pub body: Option<String>,
pub expected_status: Option<u16>,
pub captures: Vec<(String, String)>,
#[serde(default)]
pub asserts: Vec<String>,
#[serde(default)]
pub user_added: bool,
#[serde(default)]
pub modified: bool,
#[serde(skip)]
pub last_run: RunStatus,
#[serde(skip)]
pub last_response: Option<crate::http::ApiResponse>,
}
impl HurlEntry {
pub fn from_fields(
name: &str,
method: &str,
url: &str,
headers: Vec<(String, String)>,
body: &str,
) -> Self {
let headers = headers
.into_iter()
.filter(|(k, _)| !k.trim().is_empty())
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect();
let body = if body.trim().is_empty() {
None
} else {
Some(body.to_string())
};
Self {
title: name.trim().to_string(),
method: method.to_string(),
url: url.trim().to_string(),
headers,
body,
..Default::default()
}
}
pub fn ensure_run_content_length(&mut self) {
let carries_body = matches!(
self.method.to_ascii_uppercase().as_str(),
"POST" | "PUT" | "PATCH" | "DELETE"
);
let has_forms = !self.form_fields.is_empty();
let has_body = self.body.as_deref().is_some_and(|b| !b.trim().is_empty())
|| !self.form_fields.is_empty();
let has_content_length = self
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length"));
if carries_body && !has_body && !has_content_length && !has_forms {
self.headers
.push(("Content-Length".to_string(), "0".to_string()));
}
}
pub fn to_hurl(&self) -> String {
let mut out = String::new();
if !self.title.trim().is_empty() {
out.push_str("# ");
out.push_str(self.title.trim());
out.push('\n');
}
let method = if self.method.is_empty() {
"GET"
} else {
self.method.as_str()
};
out.push_str(&format!("{method} {}\n", self.url));
for (k, v) in &self.headers {
out.push_str(&format!("{k}: {v}\n"));
}
if let Some(body) = &self.body {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
if let Some((user, pass)) = &self.basic_auth {
out.push_str(&format!("[BasicAuth]\n{user}: {pass}\n"));
}
if !self.cookies.is_empty() {
out.push_str("[Cookies]\n");
for (k, v) in &self.cookies {
out.push_str(&format!("{k}: {v}\n"));
}
}
if !self.query_params.is_empty() {
out.push_str("[Query]\n");
for (k, v) in &self.query_params {
out.push_str(&format!("{k}: {v}\n"));
}
}
if !self.form_fields.is_empty() {
let multipart =
self.form_fields.iter().any(|f| f.kind.is_multipart()) || self.is_multipart;
out.push_str(if multipart {
"[Multipart]\n"
} else {
"[Form]\n"
});
for f in &self.form_fields {
match f.kind {
FormFieldKind::Text => out.push_str(&format!("{}: {}\n", f.key, f.value)),
FormFieldKind::File => {
let path = escape_form_file_path(&f.value);
match f.content_type.as_deref().map(str::trim) {
Some(ct) if !ct.is_empty() => {
out.push_str(&format!("{}: file,{}; {}\n", f.key, path, ct));
}
_ => out.push_str(&format!("{}: file,{};\n", f.key, path)),
}
}
FormFieldKind::Base64File => {
let path = escape_form_file_path(&f.value);
let encoded_prefix = URL_SAFE_NO_PAD
.encode(f.base64_prefix.as_deref().unwrap_or("").as_bytes());
out.push_str(&format!(
"{}: file,{}; {}{}\n",
f.key, path, BASE64_FILE_CT_MARKER, encoded_prefix
));
}
}
}
}
if let Some(status) = self.expected_status {
out.push_str(&format!("HTTP {status}\n"));
} else if !self.asserts.is_empty() || !self.captures.is_empty() {
out.push_str("HTTP *\n");
}
if !self.asserts.is_empty() {
out.push_str("[Asserts]\n");
for a in &self.asserts {
out.push_str(a);
out.push('\n');
}
}
if !self.captures.is_empty() {
out.push_str("[Captures]\n");
for (name, expr) in &self.captures {
out.push_str(&format!("{name}: {expr}\n"));
}
}
out
}
}
pub fn collection_to_hurl(entries: &[HurlEntry]) -> String {
entries
.iter()
.map(HurlEntry::to_hurl)
.collect::<Vec<_>>()
.join("\n")
}
pub const METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"];
pub fn method_rgb(method: &str) -> Option<(u8, u8, u8)> {
Some(match method {
"GET" => (97, 175, 239),
"POST" => (73, 204, 144),
"PUT" => (252, 161, 48),
"DELETE" => (248, 81, 73),
"PATCH" => (80, 227, 194),
"ANY" => (252, 161, 48),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(method: &str) -> HurlEntry {
HurlEntry {
method: method.to_string(),
url: "http://x/y".to_string(),
..Default::default()
}
}
#[test]
fn bodyless_post_gets_an_explicit_content_length_zero() {
let mut e = entry("POST");
e.ensure_run_content_length();
assert!(
e.headers
.iter()
.any(|(k, v)| k == "Content-Length" && v == "0")
);
}
#[test]
fn content_length_added_for_all_body_carrying_methods() {
for m in ["POST", "PUT", "PATCH", "DELETE", "post", "Put"] {
let mut e = entry(m);
e.ensure_run_content_length();
assert!(
e.headers.iter().any(|(k, _)| k == "Content-Length"),
"expected Content-Length for {m}"
);
}
}
#[test]
fn get_and_head_never_get_a_content_length() {
for m in ["GET", "HEAD"] {
let mut e = entry(m);
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length")),
"did not expect Content-Length for {m}"
);
}
}
#[test]
fn content_length_skipped_when_a_body_is_present() {
let mut e = entry("POST");
e.body = Some("{\"a\":1}".to_string());
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
);
}
#[test]
fn content_length_skipped_when_form_fields_are_present() {
let mut e = entry("POST");
e.form_fields = vec![FormField {
key: "a".to_string(),
value: "b".to_string(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
}];
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
);
}
#[test]
fn a_user_set_content_length_is_not_duplicated() {
let mut e = entry("POST");
e.headers
.push(("content-length".to_string(), "5".to_string()));
e.ensure_run_content_length();
let count = e
.headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.count();
assert_eq!(count, 1);
}
}