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,
#[serde(default)]
pub desc: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
pub struct KvRow {
pub key: String,
pub value: String,
pub enabled: bool,
pub desc: String,
}
impl KvRow {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
enabled: true,
desc: String::new(),
}
}
pub fn toggled(key: impl Into<String>, value: impl Into<String>, enabled: bool) -> Self {
Self {
key: key.into(),
value: value.into(),
enabled,
desc: String::new(),
}
}
}
impl From<(String, String, bool)> for KvRow {
fn from((key, value, enabled): (String, String, bool)) -> Self {
Self {
key,
value,
enabled,
desc: String::new(),
}
}
}
#[cfg(test)]
impl PartialEq<(String, String, bool)> for KvRow {
fn eq(&self, (key, value, enabled): &(String, String, bool)) -> bool {
self.key == *key && self.value == *value && self.enabled == *enabled && self.desc.is_empty()
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum KvRowRepr {
Full {
key: String,
value: String,
#[serde(default = "enabled_by_default")]
enabled: bool,
#[serde(default)]
desc: String,
},
Legacy(String, String, bool),
}
fn enabled_by_default() -> bool {
true
}
impl<'de> Deserialize<'de> for KvRow {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match KvRowRepr::deserialize(d)? {
KvRowRepr::Full {
key,
value,
enabled,
desc,
} => KvRow {
key,
value,
enabled,
desc,
},
KvRowRepr::Legacy(key, value, enabled) => KvRow {
key,
value,
enabled,
desc: String::new(),
},
})
}
}
pub(crate) const DESC_MARKER: &str = "# @desc ";
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, row: &KvRow) {
push_desc(out, &row.desc);
push_line(out, &format!("{}: {}", row.key, row.value), row.enabled);
}
fn push_desc(out: &mut String, desc: &str) {
for line in desc.lines() {
out.push_str(DESC_MARKER);
out.push_str(line);
out.push('\n');
}
}
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<KvRow>,
pub basic_auth: Option<(String, String)>,
#[serde(default)]
pub form_fields: Vec<FormField>,
#[serde(default)]
pub is_multipart: bool,
pub queries: Vec<KvRow>,
#[serde(default)]
pub cookies: Vec<KvRow>,
#[serde(default)]
pub options: Vec<KvRow>,
pub body: Option<String>,
pub expected_status: Option<u16>,
#[serde(default)]
pub response_version: Option<String>,
#[serde(default)]
pub response_headers: Vec<KvRow>,
#[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<KvRow>,
body: &str,
) -> Self {
let headers = headers
.into_iter()
.filter(|r| !r.key.trim().is_empty())
.map(|r| KvRow {
key: r.key.trim().to_string(),
value: r.value.trim().to_string(),
enabled: r.enabled,
desc: r.desc,
})
.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(|r| r.key.eq_ignore_ascii_case("content-length"));
if carries_body && !has_body && !has_content_length && !has_forms {
self.headers.push(KvRow::new("Content-Length", "0"));
}
}
pub fn first_empty_file_field(&self) -> Option<&str> {
self.form_fields
.iter()
.find(|f| f.enabled && f.kind.is_multipart() && f.value.trim().is_empty())
.map(|f| f.key.as_str())
}
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 row in &self.headers {
push_kv_line(&mut out, row);
}
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 row in &self.cookies {
push_kv_line(&mut out, row);
}
}
push_comments(&mut out, Query);
if !self.queries.is_empty() {
out.push_str("[Query]\n");
for row in &self.queries {
push_kv_line(&mut out, row);
}
}
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_desc(&mut out, &f.desc);
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 row in &self.options {
push_kv_line(&mut out, row);
}
}
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 row in &self.response_headers {
push_kv_line(&mut out, row);
}
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(|r| r.key == "Content-Length" && r.value == "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(|r| r.key == "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(|r| r.key.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(|r| r.key.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,
desc: String::new(),
}];
e.ensure_run_content_length();
assert!(
!e.headers
.iter()
.any(|r| r.key.eq_ignore_ascii_case("content-length"))
);
}
#[test]
fn a_user_set_content_length_is_not_duplicated() {
let mut e = entry("POST");
e.headers.push(KvRow::toggled("content-length", "5", true));
e.ensure_run_content_length();
let count = e
.headers
.iter()
.filter(|r| r.key.eq_ignore_ascii_case("content-length"))
.count();
assert_eq!(count, 1);
}
#[test]
fn a_row_saved_before_descriptions_existed_still_loads() {
let legacy: KvRow = serde_json::from_str(r#"["X-Trace","on",false]"#)
.expect("the legacy three-element form must still deserialise");
assert_eq!(legacy.key, "X-Trace");
assert_eq!(legacy.value, "on");
assert!(!legacy.enabled);
assert_eq!(legacy.desc, "", "with no note, of course");
}
#[test]
fn a_described_row_round_trips_through_the_saved_state_format() {
let row = KvRow {
key: "X-Trace".into(),
value: "on".into(),
enabled: true,
desc: "staging only".into(),
};
let back: KvRow = serde_json::from_str(&serde_json::to_string(&row).unwrap()).unwrap();
assert_eq!(back.desc, "staging only");
assert_eq!(back.key, "X-Trace");
}
}