use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use crate::hurl::{FormField, FormFieldKind, HurlEntry, KvRow, parse_hurl};
#[derive(Deserialize, Default)]
#[serde(default)]
struct Collection {
item: Vec<Item>,
variable: Vec<Param>,
auth: Option<Auth>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Item {
name: String,
item: Option<Vec<Item>>,
request: Option<Request>,
auth: Option<Auth>,
event: Vec<Event>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Event {
listen: String,
script: Script,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Script {
exec: Vec<String>,
}
#[derive(Deserialize)]
struct Request {
#[serde(default = "get_method")]
method: String,
#[serde(default, deserialize_with = "de_url")]
url: String,
#[serde(default)]
header: Vec<Param>,
auth: Option<Auth>,
body: Option<Body>,
}
fn get_method() -> String {
"GET".to_string()
}
fn de_url<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(match Value::deserialize(d)? {
Value::String(s) => s,
Value::Object(m) => m
.get("raw")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => String::new(),
})
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
struct Auth {
#[serde(rename = "type")]
kind: String,
basic: Vec<Param>,
bearer: Vec<Param>,
apikey: Vec<Param>,
}
impl Auth {
fn field(list: &[Param], name: &str) -> String {
list.iter()
.find(|p| p.key == name)
.map(|p| p.value.clone())
.unwrap_or_default()
}
fn is_noauth(&self) -> bool {
self.kind == "noauth"
}
fn inherits(&self) -> bool {
self.kind == "inherit" || self.kind.is_empty()
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Body {
mode: String,
raw: String,
urlencoded: Vec<Param>,
formdata: Vec<Param>,
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
struct Param {
#[serde(deserialize_with = "de_str")]
key: String,
#[serde(deserialize_with = "de_str")]
value: String,
disabled: bool,
#[serde(rename = "type", deserialize_with = "de_str")]
kind: String,
#[serde(deserialize_with = "de_str")]
src: String,
#[serde(rename = "contentType")]
content_type: Option<String>,
#[serde(default, deserialize_with = "de_str")]
description: String,
}
fn de_str<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
}
impl Param {
fn enabled_kve(&self) -> Option<KvRow> {
(!self.key.is_empty()).then(|| KvRow {
key: self.key.clone(),
value: self.value.clone(),
enabled: !self.disabled,
desc: self.description.clone(),
})
}
fn form_field(&self) -> Option<FormField> {
if self.disabled || self.key.is_empty() {
return None;
}
Some(if self.kind == "file" {
FormField {
key: self.key.clone(),
value: self.src.clone(),
kind: FormFieldKind::File,
content_type: self.content_type.clone(),
enabled: !self.src.trim().is_empty(),
desc: self.description.clone(),
base64_prefix: None,
}
} else {
FormField {
key: self.key.clone(),
value: self.value.clone(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: self.description.clone(),
}
})
}
}
fn unwrap_envelope(v: Value, key: &str, marker: &str) -> Value {
match v {
Value::Object(mut m) => match m.remove(key) {
Some(inner @ Value::Object(_)) if inner.get(marker).is_some() => inner,
other => {
if let Some(other) = other {
m.insert(key.to_string(), other);
}
Value::Object(m)
}
},
other => other,
}
}
pub fn looks_like_postman(content: &str) -> bool {
serde_json::from_str::<Value>(content)
.map(|v| unwrap_envelope(v, "collection", "item"))
.map(|v| v.get("info").is_some() && v.get("item").is_some())
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportKind {
Collection,
Environment,
}
pub fn export_kind(content: &str) -> Option<ExportKind> {
if looks_like_postman(content) {
Some(ExportKind::Collection)
} else if postman_env_values(content).is_some() {
Some(ExportKind::Environment)
} else {
None
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct PostmanEnv {
values: Vec<EnvValue>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct EnvValue {
#[serde(deserialize_with = "de_str")]
key: String,
#[serde(deserialize_with = "de_str")]
value: String,
enabled: Option<bool>,
}
pub fn postman_env_values(content: &str) -> Option<Vec<(String, String)>> {
let v = serde_json::from_str::<Value>(content).ok()?;
let v = unwrap_envelope(v, "environment", "values");
if !v.get("values").is_some_and(Value::is_array) || v.get("item").is_some() {
return None;
}
let env = serde_json::from_value::<PostmanEnv>(v).ok()?;
Some(
env.values
.into_iter()
.filter(|v| v.enabled.unwrap_or(true) && !v.key.trim().is_empty())
.map(|v| {
let value = v.value.replace(['\n', '\r'], " ");
(v.key.trim().to_string(), value.trim().to_string())
})
.collect(),
)
}
pub fn parse_collection(content: &str) -> Vec<HurlEntry> {
if looks_like_postman(content) {
import_postman(content)
} else {
parse_hurl(content)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversionNote {
pub item: String,
pub detail: String,
}
#[derive(Debug, Default)]
pub struct ConvertedCollection {
pub entries: Vec<HurlEntry>,
pub variables: Vec<(String, String)>,
pub notes: Vec<ConversionNote>,
}
pub fn import_postman(content: &str) -> Vec<HurlEntry> {
convert_postman(content).entries
}
pub fn convert_postman(content: &str) -> ConvertedCollection {
let Ok(root) = serde_json::from_str::<Value>(content)
.map(|v| unwrap_envelope(v, "collection", "item"))
.and_then(serde_json::from_value::<Collection>)
else {
return ConvertedCollection::default();
};
let mut out = ConvertedCollection {
variables: root
.variable
.iter()
.filter(|v| !v.disabled && !v.key.trim().is_empty())
.map(|v| {
(
v.key.trim().to_string(),
v.value.replace(['\n', '\r'], " ").trim().to_string(),
)
})
.collect(),
..ConvertedCollection::default()
};
let inherited = root.auth.as_ref().filter(|a| !a.inherits());
walk_items(&root.item, &mut Vec::new(), inherited, &mut out);
out
}
fn walk_items(
items: &[Item],
path: &mut Vec<String>,
inherited: Option<&Auth>,
out: &mut ConvertedCollection,
) {
for it in items {
if let Some(sub) = &it.item {
let here = resolve_auth(it.auth.as_ref(), inherited);
path.push(it.name.clone());
walk_items(sub, path, here, out);
path.pop();
} else if let Some(req) = &it.request {
let title = if path.is_empty() {
it.name.clone()
} else {
format!("{}/{}", path.join("/"), it.name)
};
let auth = resolve_auth(req.auth.as_ref(), inherited);
let mut entry = map_request(&title, req, &it.event, auth);
note_losses(&title, req, &it.event, auth, &entry, out);
for name in rename_dynamic_variables(&mut entry) {
out.notes.push(ConversionNote {
item: title.clone(),
detail: format!(
"Postman generated `{{{{${name}}}}}` for you; Hurl has no equivalent, so it \
became the variable `{{{{{name}}}}}`, which has to be supplied"
),
});
}
out.entries.push(entry);
}
}
}
fn rename_dynamic_variables(entry: &mut HurlEntry) -> Vec<String> {
static DYNAMIC_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{\s*\$([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}").unwrap());
let mut found: Vec<String> = Vec::new();
let mut fix = |text: &mut String| {
if !text.contains("{{") {
return;
}
let replaced = DYNAMIC_RE.replace_all(text, |caps: ®ex::Captures| {
let name = caps[1].replace('.', "_");
if !found.contains(&name) {
found.push(name.clone());
}
format!("{{{{{name}}}}}")
});
if let std::borrow::Cow::Owned(new) = replaced {
*text = new;
}
};
fix(&mut entry.url);
for row in entry
.headers
.iter_mut()
.chain(entry.queries.iter_mut())
.chain(entry.cookies.iter_mut())
{
fix(&mut row.value);
}
for f in &mut entry.form_fields {
fix(&mut f.value);
}
if let Some(body) = entry.body.as_mut() {
fix(body);
}
if let Some((user, pass)) = entry.basic_auth.as_mut() {
fix(user);
fix(pass);
}
found
}
fn resolve_auth<'a>(own: Option<&'a Auth>, inherited: Option<&'a Auth>) -> Option<&'a Auth> {
match own {
Some(a) if a.is_noauth() => None,
Some(a) if !a.inherits() => Some(a),
_ => inherited,
}
}
fn note_losses(
title: &str,
req: &Request,
events: &[Event],
auth: Option<&Auth>,
entry: &HurlEntry,
out: &mut ConvertedCollection,
) {
let mut note = |detail: String| {
out.notes.push(ConversionNote {
item: title.to_string(),
detail,
})
};
if let Some(auth) = auth
&& !matches!(auth.kind.as_str(), "basic" | "bearer" | "apikey")
{
note(format!(
"auth type `{}` has no Hurl equivalent and was dropped",
auth.kind
));
}
if let Some(auth) = auth
&& auth.kind == "apikey"
&& !matches!(Auth::field(&auth.apikey, "in").as_str(), "" | "header")
{
note("API-key auth is sent in the query string; it was added as a query parameter".into());
}
if let Some(b) = &req.body
&& !matches!(b.mode.as_str(), "" | "raw" | "urlencoded" | "formdata")
{
note(format!("body mode `{}` was dropped", b.mode));
}
for f in &entry.form_fields {
if f.kind == FormFieldKind::File && !f.enabled {
note(format!(
"the file part `{}` had no file chosen in Postman, so it is switched off until one is",
f.key
));
}
}
if events
.iter()
.any(|e| e.listen == "prerequest" && !e.script.exec.is_empty())
{
note("a pre-request script was dropped — Hurl has no equivalent".into());
}
let has_tests = events
.iter()
.any(|e| e.listen == "test" && !e.script.exec.is_empty());
if has_tests && entry.captures.is_empty() {
note("a test script was dropped — nothing in it reduced to a [Captures] entry".into());
} else if has_tests {
note("a test script was read for [Captures] only; its assertions were dropped".into());
}
}
fn map_request(name: &str, req: &Request, events: &[Event], auth: Option<&Auth>) -> HurlEntry {
let mut headers: Vec<KvRow> = req.header.iter().filter_map(Param::enabled_kve).collect();
let mut queries: Vec<KvRow> = Vec::new();
let mut basic_auth = None;
if let Some(auth) = auth {
match auth.kind.as_str() {
"basic" => {
let u = Auth::field(&auth.basic, "username");
let p = Auth::field(&auth.basic, "password");
if !u.is_empty() || !p.is_empty() {
basic_auth = Some((u, p));
}
}
"bearer" => {
let t = Auth::field(&auth.bearer, "token");
if !t.is_empty() {
headers.push(KvRow::new("Authorization", format!("Bearer {t}")));
}
}
"apikey" => {
let key = Auth::field(&auth.apikey, "key");
let value = Auth::field(&auth.apikey, "value");
if !key.is_empty() {
let row = KvRow::new(key, value);
if Auth::field(&auth.apikey, "in") == "query" {
queries.push(row);
} else {
headers.push(row);
}
}
}
_ => {}
}
}
let mut form_fields = Vec::new();
let mut body = String::new();
if let Some(b) = &req.body {
match b.mode.as_str() {
"raw" => body = b.raw.clone(),
"urlencoded" => {
form_fields = b.urlencoded.iter().filter_map(Param::form_field).collect()
}
"formdata" => form_fields = b.formdata.iter().filter_map(Param::form_field).collect(),
_ => {}
}
}
let mut entry = HurlEntry::from_fields(name, &req.method, &req.url, headers, &body);
entry.basic_auth = basic_auth;
entry.form_fields = form_fields;
entry.queries.extend(queries);
entry.captures = captures_from_events(events);
entry
}
static JSON_VAR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:var|let|const)\s+(\w+)\s*=\s*pm\.response\.json\s*\(\s*\)").unwrap()
});
static SET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"pm\.(?:environment|collectionVariables|globals|variables)\.set\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)"#,
)
.unwrap()
});
fn captures_from_events(events: &[Event]) -> Vec<(String, String)> {
let script = events
.iter()
.filter(|e| e.listen == "test")
.flat_map(|e| e.script.exec.iter())
.map(|l| l.trim_end_matches('\r'))
.collect::<Vec<_>>()
.join("\n");
if script.is_empty() {
return Vec::new();
}
let mut roots: Vec<String> = JSON_VAR_RE
.captures_iter(&script)
.map(|c| c[1].to_string())
.collect();
if roots.is_empty() {
roots.push("jsonData".to_string());
}
SET_RE
.captures_iter(&script)
.filter_map(|c| {
let path = accessor_to_jsonpath(c[2].trim(), &roots)?;
Some((c[1].to_string(), format!("jsonpath \"{path}\"")))
})
.collect()
}
fn accessor_to_jsonpath(expr: &str, roots: &[String]) -> Option<String> {
let mut s = roots.iter().find_map(|r| {
expr.strip_prefix(r.as_str())
.filter(|rest| rest.is_empty() || rest.starts_with(['.', '[']))
})?;
let mut path = String::from("$");
while !s.is_empty() {
if let Some(rest) = s.strip_prefix('.') {
let end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if end == 0 {
return None;
}
push_key(&mut path, &rest[..end]);
s = &rest[end..];
} else {
let rest = s.strip_prefix('[')?;
let close = rest.find(']')?;
let key = rest[..close].trim();
if let Some(k) = unquote(key) {
push_key(&mut path, k);
} else if !key.is_empty() && key.bytes().all(|b| b.is_ascii_digit()) {
path.push_str(&format!("[{key}]"));
} else {
return None;
}
s = &rest[close + 1..];
}
}
Some(path)
}
fn push_key(path: &mut String, key: &str) {
let simple = !key.is_empty()
&& !key.starts_with(|c: char| c.is_ascii_digit())
&& key.chars().all(|c| c.is_alphanumeric() || c == '_');
if simple {
path.push('.');
path.push_str(key);
} else {
path.push_str(&format!("['{key}']"));
}
}
fn unquote(s: &str) -> Option<&str> {
let b = s.as_bytes();
if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
Some(&s[1..s.len() - 1])
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_requests_headers_and_body() {
let json = r#"{
"info": { "name": "demo", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "folder", "item": [
{ "name": "login", "request": {
"method": "POST",
"url": { "raw": "{{url}}/login?next=1", "host": ["{{url}}"], "path": ["login"] },
"header": [
{ "key": "Content-Type", "value": "application/json", "type": "text" },
{ "key": "X-Off", "value": "no", "disabled": true }
],
"body": { "mode": "raw", "raw": "{\"u\":\"a\"}" }
}}
]},
{ "name": "form", "request": {
"method": "POST",
"url": "{{url}}/upload",
"body": { "mode": "urlencoded", "urlencoded": [
{ "key": "a", "value": "1" },
{ "key": "f", "type": "file", "src": "x" }
]}
}}
]
}"#;
assert!(looks_like_postman(json));
let e = import_postman(json);
assert_eq!(
e.len(),
2,
"folders are flattened into requests, but their path is kept in the title"
);
assert_eq!(
e[0].title, "folder/login",
"the request's folder path is preserved in its title"
);
assert_eq!(e[0].method, "POST");
assert_eq!(e[0].url, "{{url}}/login?next=1");
assert_eq!(
e[0].headers,
vec![
(
"Content-Type".to_string(),
"application/json".to_string(),
true
),
("X-Off".to_string(), "no".to_string(), false),
]
);
assert_eq!(e[0].body.as_deref(), Some("{\"u\":\"a\"}"));
assert_eq!(e[1].title, "form");
assert_eq!(
e[1].form_fields,
vec![
FormField {
key: "a".into(),
value: "1".into(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
FormField {
key: "f".into(),
value: "x".into(),
kind: FormFieldKind::File,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
],
"text and file form-data fields are both imported"
);
}
#[test]
fn bearer_auth_becomes_a_header() {
let json = r#"{"info":{},"item":[{"name":"x","request":{
"method":"GET","url":"{{url}}/me",
"auth":{"type":"bearer","bearer":[{"key":"token","value":"{{tok}}"}]}
}}]}"#;
let e = import_postman(json);
assert_eq!(e.len(), 1);
assert!(e[0].headers.contains(&KvRow::toggled(
"Authorization".to_string(),
"Bearer {{tok}}".to_string(),
true
)));
}
#[test]
fn deeply_nested_folders_build_a_full_slash_separated_path() {
let json = r#"{"info":{},"item":[
{ "name": "Auth", "item": [
{ "name": "Tokens", "item": [
{ "name": "Refresh", "request": { "method": "POST", "url": "{{url}}/refresh" } }
]},
{ "name": "Login", "request": { "method": "POST", "url": "{{url}}/login" } }
]},
{ "name": "Health", "request": { "method": "GET", "url": "{{url}}/health" } }
]}"#;
let e = import_postman(json);
assert_eq!(e.len(), 3);
assert_eq!(
e[0].title, "Auth/Tokens/Refresh",
"nesting three levels deep joins every folder name"
);
assert_eq!(e[1].title, "Auth/Login");
assert_eq!(
e[2].title, "Health",
"a top-level request keeps its bare name"
);
}
#[test]
fn non_postman_json_is_not_detected() {
assert!(!looks_like_postman("{\"foo\": 1}"));
assert!(!looks_like_postman("GET http://x/y\nHTTP 200\n"));
}
#[test]
fn enveloped_collection_export_is_detected_and_imported() {
let json = r#"{ "collection": {
"info": { "name": "demo", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "login", "request": { "method": "POST", "url": "{{url}}/login" } }
]
}}"#;
assert!(looks_like_postman(json));
let e = import_postman(json);
assert_eq!(e.len(), 1);
assert_eq!(e[0].title, "login");
assert_eq!(e[0].method, "POST");
assert_eq!(e[0].url, "{{url}}/login");
}
#[test]
fn collection_key_that_is_not_an_envelope_is_left_alone() {
let json = r#"{
"collection": "some-id",
"info": { "name": "demo" },
"item": [ { "name": "ping", "request": { "method": "GET", "url": "http://x/y" } } ]
}"#;
assert!(looks_like_postman(json));
assert_eq!(import_postman(json).len(), 1);
}
#[test]
fn form_field_with_explicit_null_value_still_imports() {
let json = r#"{
"info": {"name": "n"},
"item": [
{
"name": "upload",
"request": {
"method": "POST",
"url": "http://x/upload",
"body": {
"mode": "formdata",
"formdata": [
{"key": "doc", "value": "hi", "type": "text"},
{"key": "file", "type": "file", "value": null, "src": null},
{"key": "back", "type": "file", "src": "/tmp/a.png"}
]
}
}
}
]
}"#;
let entries = import_postman(json);
assert_eq!(entries.len(), 1);
let keys: Vec<&str> = entries[0]
.form_fields
.iter()
.map(|f| f.key.as_str())
.collect();
assert_eq!(keys, ["doc", "file", "back"]);
assert_eq!(entries[0].form_fields[2].value, "/tmp/a.png");
}
#[test]
fn accessor_chains_become_jsonpaths() {
let roots = vec!["jsonData".to_string()];
let p = |e: &str| accessor_to_jsonpath(e, &roots);
assert_eq!(p("jsonData['token']").as_deref(), Some("$.token"));
assert_eq!(p("jsonData[\"token\"]").as_deref(), Some("$.token"));
assert_eq!(p("jsonData.a.b").as_deref(), Some("$.a.b"));
assert_eq!(p("jsonData['a']['b']").as_deref(), Some("$.a.b"));
assert_eq!(p("jsonData.items[0].id").as_deref(), Some("$.items[0].id"));
assert_eq!(p("jsonData['a-b']").as_deref(), Some("$['a-b']"));
assert_eq!(p("jsonData").as_deref(), Some("$"));
assert_eq!(p("jsonData.foo()"), None);
assert_eq!(p("other['x']"), None);
}
#[test]
fn test_script_set_calls_become_captures_with_wildcard_status() {
let json = r#"{
"info": {},
"item": [
{ "name": "login", "request": { "method": "POST", "url": "{{url}}/login" },
"event": [
{ "listen": "test", "script": { "exec": [
"var jsonData = pm.response.json();\r",
"pm.environment.set(\"token\", jsonData['token']);",
"pm.collectionVariables.set(\"sid\", jsonData.session.id);"
]}}
]
}
]
}"#;
let e = import_postman(json);
assert_eq!(e.len(), 1);
assert_eq!(
e[0].captures,
vec![
("token".to_string(), "jsonpath \"$.token\"".to_string()),
("sid".to_string(), "jsonpath \"$.session.id\"".to_string()),
]
);
let text = e[0].to_hurl();
assert!(text.contains("HTTP *"), "wildcard status expected:\n{text}");
assert!(text.contains("token: jsonpath \"$.token\""));
}
#[test]
fn imported_request_without_captures_stays_bare() {
let json =
r#"{"info":{},"item":[{"name":"x","request":{"method":"GET","url":"{{u}}/a"}}]}"#;
let e = import_postman(json);
assert_eq!(e.len(), 1);
assert!(e[0].captures.is_empty());
assert!(
!e[0].to_hurl().contains("HTTP"),
"a capture-less import has no response line"
);
}
#[test]
fn postman_parameter_documentation_becomes_a_row_description() {
let json = r#"{
"info": { "name": "demo", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "search", "request": {
"method": "GET",
"url": {
"raw": "{{url}}/search?q=cats",
"host": ["{{url}}"],
"path": ["search"],
"query": [ { "key": "q", "value": "cats" } ]
},
"header": [
{ "key": "X-Trace", "value": "on", "description": "staging only" }
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "region", "value": "eu", "description": "which cluster" }
]
}
}}
]
}"#;
let entries = import_postman(json);
let e = &entries[0];
assert_eq!(
e.headers[0].desc, "staging only",
"the header's Postman documentation should survive the import"
);
assert_eq!(
e.form_fields[0].desc, "which cluster",
"and so should a form field's"
);
}
}
#[cfg(test)]
mod inheritance_tests {
use super::*;
fn nested() -> &'static str {
r#"{
"info": { "name": "demo", "schema": "https://schema.getpostman.com/..v2.1.0" },
"auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{TOKEN}}" }] },
"variable": [
{ "key": "base", "value": "https://api.example.com" },
{ "key": "off", "value": "x", "disabled": true },
{ "key": "", "value": "nameless" }
],
"item": [
{ "name": "inherits", "request": { "method": "GET", "url": "{{base}}/a" } },
{ "name": "opts out", "request": {
"method": "GET", "url": "{{base}}/b", "auth": { "type": "noauth" } } },
{ "name": "folder",
"auth": { "type": "basic", "basic": [
{ "key": "username", "value": "u" }, { "key": "password", "value": "p" } ] },
"item": [
{ "name": "deep", "request": { "method": "GET", "url": "{{base}}/c" } },
{ "name": "own", "request": { "method": "GET", "url": "{{base}}/d",
"auth": { "type": "apikey", "apikey": [
{ "key": "key", "value": "X-Key" },
{ "key": "value", "value": "{{KEY}}" } ] } } }
] }
]
}"#
}
fn header(e: &HurlEntry, name: &str) -> Option<String> {
e.headers
.iter()
.find(|h| h.key.eq_ignore_ascii_case(name))
.map(|h| h.value.clone())
}
#[test]
fn a_request_without_auth_inherits_the_collections() {
let c = convert_postman(nested());
let e = &c.entries[0];
assert_eq!(e.title, "inherits");
assert_eq!(
header(e, "Authorization").as_deref(),
Some("Bearer {{TOKEN}}")
);
}
#[test]
fn a_request_can_opt_out_of_the_inherited_auth() {
let c = convert_postman(nested());
let e = &c.entries[1];
assert_eq!(e.title, "opts out");
assert_eq!(header(e, "Authorization"), None);
assert_eq!(e.basic_auth, None);
}
#[test]
fn a_folder_overrides_the_collection_for_everything_inside_it() {
let c = convert_postman(nested());
let e = c.entries.iter().find(|e| e.title == "folder/deep").unwrap();
assert_eq!(e.basic_auth, Some(("u".to_string(), "p".to_string())));
assert_eq!(
header(e, "Authorization"),
None,
"the collection's bearer token doesn't leak past the folder"
);
}
#[test]
fn a_requests_own_auth_beats_the_folder_it_is_in() {
let c = convert_postman(nested());
let e = c.entries.iter().find(|e| e.title == "folder/own").unwrap();
assert_eq!(header(e, "X-Key").as_deref(), Some("{{KEY}}"));
assert_eq!(e.basic_auth, None, "the folder's basic auth was replaced");
}
#[test]
fn an_api_key_in_the_query_string_becomes_a_query_parameter() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "q", "request": { "method": "GET", "url": "https://x/y",
"auth": { "type": "apikey", "apikey": [
{ "key": "key", "value": "api_key" },
{ "key": "value", "value": "abc" },
{ "key": "in", "value": "query" } ] } } } ]
}"#;
let c = convert_postman(json);
let e = &c.entries[0];
assert!(
e.queries
.iter()
.any(|q| q.key == "api_key" && q.value == "abc"),
"the key rides in the query string: {:?}",
e.queries
);
assert_eq!(header(e, "api_key"), None, "and not in a header as well");
}
#[test]
fn collection_variables_are_extracted_for_a_vars_file() {
let vars = convert_postman(nested()).variables;
assert_eq!(
vars,
vec![("base".to_string(), "https://api.example.com".to_string())]
);
}
#[test]
fn what_could_not_be_converted_is_reported() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [
{ "name": "oauth", "request": { "method": "GET", "url": "https://x",
"auth": { "type": "oauth2" } } },
{ "name": "gql", "request": { "method": "POST", "url": "https://x",
"body": { "mode": "graphql" } } },
{ "name": "scripted", "request": { "method": "GET", "url": "https://x" },
"event": [ { "listen": "prerequest",
"script": { "exec": ["pm.environment.set('t', Date.now())"] } } ] }
]
}"#;
let notes = convert_postman(json).notes;
let for_item = |name: &str| {
notes
.iter()
.filter(|n| n.item == name)
.map(|n| n.detail.clone())
.collect::<Vec<_>>()
};
assert!(
for_item("oauth")[0].contains("oauth2"),
"the auth type that was lost is named: {notes:?}"
);
assert!(for_item("gql")[0].contains("graphql"));
assert!(for_item("scripted")[0].contains("pre-request"));
}
#[test]
fn a_generated_variable_is_renamed_so_the_file_still_parses() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "start", "request": { "method": "POST",
"url": "https://x/{{$guid}}",
"header": [ { "key": "X-Run", "value": "{{$timestamp}}" } ],
"body": { "mode": "raw", "raw": "{\"id\": \"{{$processEnv.HOME}}\"}" } } } ]
}"#;
let converted = convert_postman(json);
let hurl = crate::hurl::collection_to_hurl(&converted.entries);
assert!(!hurl.contains("{{$"), "a `$` is not a legal name: {hurl}");
assert!(hurl.contains("{{guid}}"), "{hurl}");
assert!(hurl.contains("{{timestamp}}"), "{hurl}");
assert!(hurl.contains("{{processEnv_HOME}}"), "{hurl}");
assert_eq!(
crate::hurl::parse_hurl(&hurl).len(),
1,
"the converted file must read back: {:?}",
crate::hurl::parse_hurl_error(&hurl)
);
assert_eq!(converted.notes.len(), 3);
assert!(
converted
.notes
.iter()
.any(|n| n.detail.contains("{{guid}}"))
);
}
#[test]
fn a_file_part_with_no_file_is_switched_off_rather_than_written_broken() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "upload", "request": { "method": "POST", "url": "https://x",
"body": { "mode": "formdata", "formdata": [
{ "key": "document_id", "value": "1", "type": "text" },
{ "key": "front_side_file", "type": "file", "src": "" }
] } } } ]
}"#;
let converted = convert_postman(json);
let hurl = crate::hurl::collection_to_hurl(&converted.entries);
assert!(
!hurl
.lines()
.any(|l| !l.trim_start().starts_with('#') && l.contains("file,;")),
"{hurl}"
);
assert_eq!(
crate::hurl::parse_hurl(&hurl).len(),
1,
"the converted file must read back: {:?}",
crate::hurl::parse_hurl_error(&hurl)
);
let back = &crate::hurl::parse_hurl(&hurl)[0];
let part = back
.form_fields
.iter()
.find(|f| f.key == "front_side_file")
.expect("the part survives as a disabled row");
assert!(!part.enabled);
assert!(
converted
.notes
.iter()
.any(|n| n.detail.contains("front_side_file")),
"the switched-off part is reported: {:?}",
converted.notes
);
}
#[test]
fn a_clean_collection_reports_nothing() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "ok", "request": { "method": "GET", "url": "https://x",
"header": [ { "key": "Accept", "value": "application/json" } ] } } ]
}"#;
assert_eq!(convert_postman(json).notes, vec![]);
}
}