use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum FormFieldKind {
#[default]
Text,
File,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FormField {
pub key: String,
pub value: String,
pub kind: FormFieldKind,
pub content_type: 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>,
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 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 == FormFieldKind::File);
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)),
}
}
}
}
}
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,
})
}