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>,
pub enabled: bool,
}
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
}
fn push_line(out: &mut String, line: &str, enabled: bool) {
if !enabled {
out.push_str("# ");
}
out.push_str(line);
out.push('\n');
}
fn push_kv_line(out: &mut String, k: &str, v: &str, enabled: bool) {
push_line(out, &format!("{k}: {v}"), enabled);
}
fn form_field_line(f: &FormField) -> String {
match f.kind {
FormFieldKind::Text => format!("{}: {}", 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() => format!("{}: file,{}; {}", f.key, path, ct),
_ => format!("{}: file,{};", 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());
format!(
"{}: file,{}; {}{}",
f.key, path, BASE64_FILE_CT_MARKER, encoded_prefix
)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunStatus {
#[default]
NotRun,
Running,
Passed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommentAnchor {
Lead,
Headers,
BasicAuth,
Cookies,
Query,
Form,
Options,
Body,
Response,
ResponseHeaders,
Asserts,
Captures,
ResponseBody,
Trailing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryComment {
pub anchor: CommentAnchor,
pub text: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HurlEntry {
pub title: String,
pub method: String,
pub url: String,
pub headers: Vec<(String, String, bool)>,
pub basic_auth: Option<(String, String)>,
#[serde(default)]
pub form_fields: Vec<FormField>,
#[serde(default)]
pub is_multipart: bool,
pub queries: Vec<(String, String, bool)>,
#[serde(default)]
pub cookies: Vec<(String, String, bool)>,
#[serde(default)]
pub options: Vec<(String, String, bool)>,
pub body: Option<String>,
pub expected_status: Option<u16>,
#[serde(default)]
pub response_version: Option<String>,
#[serde(default)]
pub response_headers: Vec<(String, String, bool)>,
#[serde(default)]
pub response_body: Option<String>,
pub captures: Vec<(String, String)>,
#[serde(default)]
pub asserts: Vec<String>,
#[serde(default)]
pub reports: Vec<(String, String)>,
#[serde(default)]
pub comments: Vec<EntryComment>,
#[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, bool)>,
body: &str,
) -> Self {
let headers = headers
.into_iter()
.filter(|(k, _, _)| !k.trim().is_empty())
.map(|(k, v, e)| (k.trim().to_string(), v.trim().to_string(), e))
.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(), true));
}
}
pub fn to_hurl(&self) -> String {
use CommentAnchor::*;
let mut out = String::new();
let push_comments = |out: &mut String, anchor: CommentAnchor| {
for c in self.comments.iter().filter(|c| c.anchor == anchor) {
out.push_str(&c.text);
out.push('\n');
}
};
push_comments(&mut out, Lead);
if self.comments.iter().any(|c| c.anchor == Lead) {
out.push('\n');
}
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));
push_comments(&mut out, Headers);
for (k, v, enabled) in &self.headers {
push_kv_line(&mut out, k, v, *enabled);
}
push_comments(&mut out, BasicAuth);
if let Some((user, pass)) = &self.basic_auth {
out.push_str(&format!("[BasicAuth]\n{user}: {pass}\n"));
}
push_comments(&mut out, Cookies);
if !self.cookies.is_empty() {
out.push_str("[Cookies]\n");
for (k, v, enabled) in &self.cookies {
push_kv_line(&mut out, k, v, *enabled);
}
}
push_comments(&mut out, Query);
if !self.queries.is_empty() {
out.push_str("[Query]\n");
for (k, v, enabled) in &self.queries {
push_kv_line(&mut out, k, v, *enabled);
}
}
push_comments(&mut out, Form);
if !self.form_fields.is_empty() {
let multipart = self
.form_fields
.iter()
.any(|f| f.enabled && f.kind.is_multipart())
|| self.is_multipart;
out.push_str(if multipart {
"[Multipart]\n"
} else {
"[Form]\n"
});
for f in &self.form_fields {
push_line(&mut out, &form_field_line(f), f.enabled);
}
}
push_comments(&mut out, Options);
if !self.options.is_empty() {
out.push_str("[Options]\n");
for (k, v, enabled) in &self.options {
push_kv_line(&mut out, k, v, *enabled);
}
}
push_comments(&mut out, Body);
if let Some(body) = &self.body {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
push_comments(&mut out, Response);
let has_response_comments = self.comments.iter().any(|c| {
matches!(
c.anchor,
Response | ResponseHeaders | Asserts | Captures | ResponseBody
)
});
let version = self.response_version.as_deref().unwrap_or("HTTP");
let has_response_area = self.expected_status.is_some()
|| self.response_version.is_some()
|| !self.response_headers.is_empty()
|| self.response_body.is_some()
|| !self.asserts.is_empty()
|| !self.captures.is_empty()
|| !self.reports.is_empty()
|| has_response_comments;
if let Some(status) = self.expected_status {
out.push_str(&format!("{version} {status}\n"));
} else if has_response_area {
out.push_str(&format!("{version} *\n"));
}
push_comments(&mut out, ResponseHeaders);
for (k, v, enabled) in &self.response_headers {
push_kv_line(&mut out, k, v, *enabled);
}
push_comments(&mut out, Asserts);
if !self.asserts.is_empty() {
out.push_str("[Asserts]\n");
for a in &self.asserts {
out.push_str(a);
out.push('\n');
}
}
push_comments(&mut out, Captures);
if !self.captures.is_empty() {
out.push_str("[Captures]\n");
for (name, expr) in &self.captures {
out.push_str(&format!("{name}: {expr}\n"));
}
}
push_comments(&mut out, ResponseBody);
if let Some(body) = &self.response_body {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
if !self.reports.is_empty() {
out.push_str("# [Reports]\n");
for (name, query) in &self.reports {
out.push_str(&format!("# {name}: {query}\n"));
}
}
push_comments(&mut out, Trailing);
out
}
}
pub fn collection_to_hurl(entries: &[HurlEntry]) -> String {
entries
.iter()
.map(HurlEntry::to_hurl)
.collect::<Vec<_>>()
.join("\n")
}
pub fn status_eq_code(expr: &str) -> Option<u16> {
let rest = expr.trim().strip_prefix("status")?;
let rest = rest.trim_start().strip_prefix("==")?;
rest.trim().parse::<u16>().ok()
}
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,
enabled: true,
}];
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(), true));
e.ensure_run_content_length();
let count = e
.headers
.iter()
.filter(|(k, _, _)| k.eq_ignore_ascii_case("content-length"))
.count();
assert_eq!(count, 1);
}
}