use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use crate::hurl::{
CommentAnchor, EntryComment, FormField, FormFieldKind, HurlEntry, KvRow, parse_hurl,
};
use crate::probe::{Predicate, Subject, assert_line, push_key};
#[derive(Deserialize, Default)]
#[serde(default)]
struct Collection {
item: Vec<Item>,
variable: Vec<Param>,
auth: Option<Auth>,
event: Vec<Event>,
#[serde(rename = "protocolProfileBehavior")]
protocol_profile_behavior: Option<Profile>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Item {
#[serde(deserialize_with = "de_str")]
name: String,
item: Option<Vec<Item>>,
request: Option<Request>,
auth: Option<Auth>,
event: Vec<Event>,
#[serde(rename = "protocolProfileBehavior")]
protocol_profile_behavior: Option<Profile>,
}
#[derive(Clone, Copy, Default, Deserialize)]
#[serde(default)]
struct Profile {
#[serde(rename = "disableBodyPruning")]
disable_body_pruning: Option<bool>,
#[serde(rename = "strictSSL")]
strict_ssl: Option<bool>,
}
impl Profile {
fn over(self, parent: Profile) -> Profile {
Profile {
disable_body_pruning: self.disable_body_pruning.or(parent.disable_body_pruning),
strict_ssl: self.strict_ssl.or(parent.strict_ssl),
}
}
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
struct Event {
#[serde(deserialize_with = "de_str")]
listen: String,
script: Script,
#[serde(skip)]
inherited: bool,
#[serde(skip)]
owner: String,
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
struct Script {
exec: Vec<String>,
}
#[derive(Deserialize)]
struct Request {
#[serde(default = "get_method", deserialize_with = "de_method")]
method: String,
#[serde(default, deserialize_with = "de_url")]
url: Url,
#[serde(default)]
header: Vec<Param>,
auth: Option<Auth>,
body: Option<Body>,
#[serde(default, deserialize_with = "de_description")]
description: String,
}
fn de_description<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(match Value::deserialize(d)? {
Value::String(s) => s,
Value::Object(m) => m
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => String::new(),
})
}
fn get_method() -> String {
"GET".to_string()
}
fn de_method<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let m = de_str(d)?;
Ok(if m.trim().is_empty() { get_method() } else { m })
}
#[derive(Default)]
struct Url {
raw: String,
fragment: String,
variables: Vec<Param>,
queries: Vec<Param>,
}
fn join_parts(v: Option<&Value>, sep: &str) -> String {
match v {
Some(Value::Array(parts)) => parts
.iter()
.map(|p| match p {
Value::String(s) => s.clone(),
Value::Object(o) => o
.get("value")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
other => other.as_str().unwrap_or_default().to_string(),
})
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(sep),
Some(Value::String(s)) => s.clone(),
_ => String::new(),
}
}
fn url_from_parts(m: &serde_json::Map<String, Value>) -> String {
let host = join_parts(m.get("host"), ".");
if host.is_empty() {
return String::new();
}
let mut url = String::new();
let protocol = m.get("protocol").and_then(Value::as_str).unwrap_or("");
if !protocol.is_empty() {
url.push_str(protocol);
url.push_str("://");
}
url.push_str(&host);
if let Some(port) = m
.get("port")
.and_then(Value::as_str)
.filter(|p| !p.is_empty())
{
url.push(':');
url.push_str(port);
}
let path = join_parts(m.get("path"), "/");
if !path.is_empty() {
if !path.starts_with('/') {
url.push('/');
}
url.push_str(&path);
}
url
}
fn merge_enabled_queries(raw: &str, queries: &[Param]) -> String {
let mut existing: Vec<&str> = raw
.split_once('?')
.map(|(_, q)| {
q.split('&')
.map(|p| p.split_once('=').map_or(p, |(k, _)| k))
.collect()
})
.unwrap_or_default();
let missing: Vec<String> = queries
.iter()
.filter(|q| !q.disabled && !q.key.trim().is_empty())
.filter(|q| {
match existing.iter().position(|k| *k == q.key.as_str()) {
Some(i) => {
existing.remove(i);
false
}
None => true,
}
})
.map(|q| {
if q.value.is_empty() {
q.key.clone()
} else {
format!("{}={}", q.key, q.value)
}
})
.collect();
if missing.is_empty() {
return raw.to_string();
}
let sep = if raw.contains('?') { '&' } else { '?' };
format!("{raw}{sep}{}", missing.join("&"))
}
fn de_url<'de, D: Deserializer<'de>>(d: D) -> Result<Url, D::Error> {
Ok(match Value::deserialize(d)? {
Value::String(s) => {
let (raw, fragment) = split_fragment(&s);
Url {
raw,
fragment,
variables: Vec::new(),
queries: Vec::new(),
}
}
Value::Object(m) => Url {
raw: String::new(),
fragment: String::new(),
variables: m
.get("variable")
.cloned()
.and_then(|v| serde_json::from_value::<Vec<Param>>(v).ok())
.unwrap_or_default(),
queries: m
.get("query")
.cloned()
.and_then(|v| serde_json::from_value::<Vec<Param>>(v).ok())
.unwrap_or_default(),
}
.with_raw_from(&m),
_ => Url::default(),
})
}
fn split_fragment(url: &str) -> (String, String) {
match url.find('#') {
Some(0) | None => (url.to_string(), String::new()),
Some(i) => (url[..i].to_string(), url[i..].to_string()),
}
}
impl Url {
fn with_raw_from(mut self, m: &serde_json::Map<String, Value>) -> Self {
let raw = m.get("raw").and_then(Value::as_str).unwrap_or("").trim();
let base = if raw.is_empty() {
url_from_parts(m)
} else {
raw.to_string()
};
let (kept, fragment) = split_fragment(&merge_enabled_queries(&base, &self.queries));
self.raw = kept;
self.fragment = fragment;
self
}
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
struct Auth {
#[serde(rename = "type", deserialize_with = "de_str")]
kind: String,
basic: Vec<Param>,
bearer: Vec<Param>,
apikey: Vec<Param>,
oauth2: Vec<Param>,
awsv4: 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 {
#[serde(deserialize_with = "de_str")]
mode: String,
#[serde(deserialize_with = "de_str")]
raw: String,
urlencoded: Vec<Param>,
formdata: Vec<Param>,
file: FileBody,
graphql: GraphQl,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct FileBody {
#[serde(deserialize_with = "de_str")]
src: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct GraphQl {
#[serde(deserialize_with = "de_str")]
query: String,
#[serde(deserialize_with = "de_json_str")]
variables: String,
}
#[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_json_str<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(match Value::deserialize(d)? {
Value::String(s) => s,
v @ (Value::Object(_) | Value::Array(_)) => v.to_string(),
Value::Null => String::new(),
other => other.to_string(),
})
}
fn de_str<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(match Value::deserialize(d)? {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null | Value::Array(_) | Value::Object(_) => String::new(),
})
}
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>,
#[serde(rename = "type", default, deserialize_with = "de_str")]
kind: String,
}
pub fn postman_env_values(content: &str) -> Option<Vec<(String, String)>> {
Some(
env_values(content)?
.into_iter()
.map(|(k, v, _)| (k, v))
.collect(),
)
}
pub fn postman_env_secret_keys(content: &str) -> Vec<String> {
env_values(content)
.unwrap_or_default()
.into_iter()
.filter(|(_, _, secret)| *secret)
.map(|(k, _, _)| k)
.collect()
}
pub fn postman_env_unresolved_refs(content: &str) -> Vec<(String, String)> {
env_values(content)
.unwrap_or_default()
.into_iter()
.flat_map(|(k, v, _)| {
ENV_REF_RE
.captures_iter(&v)
.map(|c| c[1].trim().to_string())
.filter(|n| !is_provider_reference(n))
.map(|n| (k.clone(), n))
.collect::<Vec<_>>()
})
.collect()
}
static ENV_REF_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{\s*([^{}]+?)\s*\}\}").unwrap());
fn is_provider_reference(inner: &str) -> bool {
inner.starts_with("op://") || inner.starts_with("ssm:") || inner.starts_with("env:")
}
fn resolve_env_refs(values: &mut [(String, String, bool)]) {
for _ in 0..8 {
let known: Vec<(String, String)> = values
.iter()
.map(|(k, v, _)| (k.clone(), v.clone()))
.collect();
let mut changed = false;
for (key, value, _) in values.iter_mut() {
let next = ENV_REF_RE
.replace_all(value, |c: ®ex::Captures| {
let name = c[1].trim();
if name == key || is_provider_reference(name) {
return c[0].to_string();
}
known
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| c[0].to_string())
})
.into_owned();
if next != *value {
*value = next;
changed = true;
}
}
if !changed {
break;
}
}
}
fn env_values(content: &str) -> Option<Vec<(String, String, bool)>> {
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()?;
let mut values: Vec<(String, String, bool)> = 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'], " ");
let secret = v.kind == "secret";
(v.key.trim().to_string(), value.trim().to_string(), secret)
})
.collect();
resolve_env_refs(&mut values);
Some(values)
}
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 root = match serde_json::from_str::<Value>(content)
.map(|v| unwrap_envelope(v, "collection", "item"))
.and_then(serde_json::from_value::<Collection>)
{
Ok(root) => root,
Err(e) => {
return ConvertedCollection {
notes: vec![ConversionNote {
item: String::new(),
detail: format!("collection could not be read: {e}"),
}],
..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());
let mut tokens = OAuthTokens::default();
walk_items(
&root.item,
&mut Vec::new(),
inherited,
&[],
&inherited_events(&[], &root.event, &[]),
root.protocol_profile_behavior.unwrap_or_default(),
&mut tokens,
&mut out,
);
out
}
fn inherited_events(from_above: &[Event], own: &[Event], owner: &[String]) -> Vec<Event> {
from_above
.iter()
.cloned()
.chain(own.iter().cloned().map(|mut e| {
e.inherited = true;
e.owner = owner.join("/");
e
}))
.collect()
}
fn walk_items(
items: &[Item],
path: &mut Vec<String>,
inherited: Option<&Auth>,
auth_path: &[String],
events: &[Event],
profile: Profile,
tokens: &mut OAuthTokens,
out: &mut ConvertedCollection,
) {
for it in items {
if let Some(sub) = &it.item {
let here = resolve_auth(it.auth.as_ref(), inherited);
let declares_own = it.auth.as_ref().is_some_and(|a| !a.inherits());
path.push(it.name.clone());
let here_path = if declares_own {
path.clone()
} else {
auth_path.to_vec()
};
let here_profile = it
.protocol_profile_behavior
.unwrap_or_default()
.over(profile);
let here_events = inherited_events(events, &it.event, path);
walk_items(
sub,
path,
here,
&here_path,
&here_events,
here_profile,
tokens,
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 declares_own = req.auth.as_ref().is_some_and(|a| !a.inherits());
let token_path: &[String] = if declares_own { path } else { auth_path };
let profile = it
.protocol_profile_behavior
.unwrap_or_default()
.over(profile);
let events: Vec<Event> = events.iter().cloned().chain(it.event.clone()).collect();
let mut entry = map_request(&title, req, &events, auth, profile);
apply_path_variables(&title, &req.url, &mut entry, out);
apply_oauth2(&title, token_path, auth, &mut entry, tokens, out);
note_losses(&title, req, &events, auth, profile, &entry, out);
for (name, fate) in rename_dynamic_variables(&mut entry) {
let detail = match fate {
DynamicFate::Builtin(f) => format!(
"Postman generated `{{{{${name}}}}}` for you; Hurl generates the same \
thing, so it became `{{{{{f}}}}}` and still needs nothing supplied"
),
DynamicFate::Computed(expr) => format!(
"Postman generated `{{{{${name}}}}}` for you; it is now computed by this \
request's `[Gen]` block as `{expr}`, once per send rather than once per \
use"
),
DynamicFate::Supplied => format!(
"Postman generated `{{{{${name}}}}}` for you; nothing here can produce it, \
so it became the variable `{{{{{plain}}}}}`, which has to be supplied",
plain = name.replace('.', "_")
),
};
out.notes.push(ConversionNote {
item: title.clone(),
detail,
});
}
out.entries.push(entry);
}
}
}
struct OAuth2 {
access_token_url: String,
grant_type: String,
client_id: String,
client_secret: String,
username: String,
password: String,
scope: String,
client_authentication: String,
header_prefix: String,
add_token_to: String,
}
impl OAuth2 {
fn read(auth: &Auth) -> Self {
let f = |name: &str| Auth::field(&auth.oauth2, name);
let prefix = match (f("headerPrefix"), f("tokenType")) {
(p, _) if !p.trim().is_empty() => p,
(_, t) if !t.trim().is_empty() => format!("{} ", t.trim()),
_ => "Bearer ".to_string(),
};
OAuth2 {
access_token_url: f("accessTokenUrl"),
grant_type: f("grant_type"),
client_id: f("clientId"),
client_secret: f("clientSecret"),
username: f("username"),
password: f("password"),
scope: f("scope"),
client_authentication: f("client_authentication"),
header_prefix: prefix,
add_token_to: f("addTokenTo"),
}
}
fn identity(&self) -> String {
format!(
"{}|{}|{}|{}|{}|{}|{}|{}",
self.access_token_url,
self.grant_type,
self.client_id,
self.client_secret,
self.username,
self.password,
self.scope,
self.client_authentication
)
}
}
#[derive(Default)]
struct OAuthTokens {
issued: Vec<(String, String)>,
}
impl OAuthTokens {
fn var_for(&self, identity: &str) -> Option<&str> {
self.issued
.iter()
.find(|(id, _)| id == identity)
.map(|(_, var)| var.as_str())
}
fn next_var(&self, taken: &[(String, String)]) -> String {
let used = |name: &str| {
taken.iter().any(|(k, _)| k == name) || self.issued.iter().any(|(_, v)| v == name)
};
if self.issued.is_empty() && !used("access_token") {
return "access_token".to_string();
}
let mut n = self.issued.len().max(1);
loop {
n += 1;
let candidate = format!("access_token_{n}");
if !used(&candidate) {
return candidate;
}
}
}
}
fn apply_oauth2(
title: &str,
path: &[String],
auth: Option<&Auth>,
entry: &mut HurlEntry,
tokens: &mut OAuthTokens,
out: &mut ConvertedCollection,
) {
let Some(auth) = auth.filter(|a| a.kind == "oauth2") else {
return;
};
let cfg = OAuth2::read(auth);
let mut note = |detail: String| {
out.notes.push(ConversionNote {
item: title.to_string(),
detail,
})
};
let already_authorized = if cfg.add_token_to == "queryParams" {
entry.queries.iter().any(|q| q.key == "access_token")
} else {
entry
.headers
.iter()
.any(|h| h.key.eq_ignore_ascii_case("authorization"))
};
if already_authorized {
note(
"this request sets its own Authorization, so the folder's OAuth 2 token was not \
added on top of it"
.into(),
);
return;
}
let var = if cfg.access_token_url.trim().is_empty() {
match tokens.issued.last() {
Some((_, var)) => var.clone(),
None => {
note(
"OAuth 2 auth with no token URL — Postman was holding a token it fetched \
elsewhere, which an export can't carry, so this request has no credentials"
.into(),
);
return;
}
}
} else if !matches!(cfg.grant_type.as_str(), "client_credentials" | "password") {
note(format!(
"the OAuth 2 `{}` grant needs a browser redirect, which a file of requests can't \
perform — fetch a token by hand and put it in a variable",
cfg.grant_type
));
return;
} else {
let identity = cfg.identity();
match tokens.var_for(&identity) {
Some(var) => var.to_string(),
None => {
let var = tokens.next_var(&out.variables);
let (token_entry, missing) = token_request(&cfg, &var, path);
if missing {
note(
"Postman keeps OAuth 2 client credentials outside the export, so the \
generated token request refers to `{{oauth_client_id}}` and \
`{{oauth_client_secret}}` — fill them in alongside the collection"
.into(),
);
}
out.entries.push(token_entry);
tokens.issued.push((identity, var.clone()));
var
}
}
};
if cfg.add_token_to == "queryParams" {
entry
.queries
.push(KvRow::new("access_token", format!("{{{{{var}}}}}")));
} else {
entry.headers.push(KvRow::new(
"Authorization",
format!("{}{{{{{var}}}}}", cfg.header_prefix),
));
}
}
fn token_request(cfg: &OAuth2, var: &str, path: &[String]) -> (HurlEntry, bool) {
let missing = cfg.client_id.trim().is_empty() && cfg.client_secret.trim().is_empty();
let (id, secret) = if missing {
(
"{{oauth_client_id}}".to_string(),
"{{oauth_client_secret}}".to_string(),
)
} else {
(cfg.client_id.clone(), cfg.client_secret.clone())
};
let mut form: Vec<FormField> = Vec::new();
let mut text = |key: &str, value: String| {
form.push(FormField {
key: key.to_string(),
value,
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
})
};
text("grant_type", cfg.grant_type.clone());
if !cfg.scope.trim().is_empty() {
text("scope", cfg.scope.clone());
}
if cfg.grant_type == "password" {
text("username", cfg.username.clone());
text("password", cfg.password.clone());
}
let basic_auth = if cfg.client_authentication == "body" {
text("client_id", id);
text("client_secret", secret);
None
} else {
Some((id, secret))
};
let title = if path.is_empty() {
"Get access token".to_string()
} else {
format!("{}/Get access token", path.join("/"))
};
let entry = HurlEntry {
title,
method: "POST".to_string(),
url: cfg.access_token_url.clone(),
form_fields: form,
basic_auth,
expected_status: Some(200),
captures: vec![(var.to_string(), "jsonpath \"$.access_token\"".to_string())],
..Default::default()
};
(entry, missing)
}
fn apply_path_variables(
title: &str,
url: &Url,
entry: &mut HurlEntry,
out: &mut ConvertedCollection,
) {
let declared: Vec<&Param> = url
.variables
.iter()
.filter(|v| !v.disabled && !v.key.trim().is_empty())
.collect();
if declared.is_empty() {
return;
}
let (path, query) = match entry.url.split_once('?') {
Some((p, q)) => (p.to_string(), Some(q.to_string())),
None => (entry.url.clone(), None),
};
let rewritten: Vec<String> = path
.split('/')
.map(|seg| match seg.strip_prefix(':') {
Some(name) if declared.iter().any(|v| v.key.trim() == name) => {
format!("{{{{{name}}}}}")
}
_ => seg.to_string(),
})
.collect();
entry.url = match query {
Some(q) => format!("{}?{}", rewritten.join("/"), q),
None => rewritten.join("/"),
};
for var in declared {
let key = var.key.trim().to_string();
let value = var.value.replace(['\n', '\r'], " ").trim().to_string();
match out.variables.iter().find(|(k, _)| *k == key) {
Some((_, existing)) if *existing != value && !value.is_empty() => {
out.notes.push(ConversionNote {
item: title.to_string(),
detail: format!(
"the path variable `{key}` is declared here as `{value}` but is already \
`{existing}` — a `.vars` file holds one value per name, so the first was \
kept"
),
});
}
Some(_) => {}
None => out.variables.push((key, value)),
}
}
}
enum DynamicFate {
Builtin(&'static str),
Computed(&'static str),
Supplied,
}
fn dynamic_fate(name: &str) -> DynamicFate {
match name {
"guid" | "randomUUID" => DynamicFate::Builtin("newUuid"),
"isoTimestamp" => DynamicFate::Builtin("newDate"),
"timestamp" => DynamicFate::Computed("timestamp"),
"randomInt" => DynamicFate::Computed("random_int(0, 1000)"),
"randomAlphaNumeric" => DynamicFate::Computed("random_alnum(1)"),
_ => DynamicFate::Supplied,
}
}
fn rename_dynamic_variables(entry: &mut HurlEntry) -> Vec<(String, DynamicFate)> {
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, DynamicFate)> = Vec::new();
let mut rows: Vec<(String, String)> = Vec::new();
let existing: Vec<(String, String)> = entry.generators.clone();
let mut fix = |text: &mut String| {
if !text.contains("{{") {
return;
}
let replaced = DYNAMIC_RE.replace_all(text, |caps: ®ex::Captures| {
let raw = &caps[1];
let fate = dynamic_fate(raw);
let plain = raw.replace('.', "_");
let name = match fate {
DynamicFate::Builtin(f) => f.to_string(),
DynamicFate::Computed(expr) => {
let taken = |n: &str| {
existing
.iter()
.chain(rows.iter())
.find(|(name, _)| name == n)
.map(|(_, e)| e.clone())
};
let mut candidate = plain.clone();
let mut suffix = 0;
loop {
match taken(&candidate) {
None => {
rows.push((candidate.clone(), expr.to_string()));
break;
}
Some(e) if e == expr => break,
Some(_) => {
suffix += 1;
candidate = format!("{plain}_{suffix}");
}
}
}
candidate
}
DynamicFate::Supplied => plain.clone(),
};
if !found.iter().any(|(n, _)| n == raw) {
found.push((raw.to_string(), fate));
}
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_src.as_mut() {
fix(body);
}
if let Some((user, pass)) = entry.basic_auth.as_mut() {
fix(user);
fix(pass);
}
for (name, expr) in rows {
if !entry.generators.iter().any(|(n, _)| *n == name) {
entry.generators.push((name, expr));
}
}
found
}
fn non_empty(value: String, fallback: &str) -> String {
if value.trim().is_empty() {
fallback.to_string()
} else {
value
}
}
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>,
profile: Profile,
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" | "oauth2" | "awsv4"
)
{
note(format!(
"auth type `{}` has no Hurl equivalent and was dropped",
auth.kind
));
}
if let Some(auth) = auth
&& auth.kind == "awsv4"
&& Auth::field(&auth.awsv4, "accessKey").trim().is_empty()
{
note(
"AWS auth carried no keys — real exports keep them in variables or outside the file \
— so the request signs with `{{aws_access_key_id}}` and `{{aws_secret_access_key}}`"
.into(),
);
}
if !req.url.fragment.is_empty() {
note(format!(
"the URL fragment `{}` was left off: a fragment is never sent to the server (Postman \
doesn't send it either), and a `#` on the request line would comment out the rest \
of the URL",
req.url.fragment
));
}
if let Some(auth) = auth
&& auth.kind == "apikey"
&& Auth::field(&auth.apikey, "in") == "query"
{
note("API-key auth is sent in the query string; it was added as a query parameter".into());
}
let pruned =
matches!(req.method.as_str(), "GET" | "HEAD") && profile.disable_body_pruning != Some(true);
if let Some(b) = &req.body
&& pruned
&& !(b.raw.is_empty() && b.mode.is_empty())
{
note(format!(
"the stored {} body was left out, because Postman would not have sent it on a {} \
either — nothing turned its body pruning off",
if b.mode.is_empty() {
"request"
} else {
&b.mode
},
req.method
));
} else if let Some(b) = &req.body {
match b.mode.as_str() {
"" | "raw" | "urlencoded" | "formdata" => {}
"file" if b.file.src.trim().is_empty() => note(
"the body is a file, and Postman never had one chosen — attach it here instead"
.into(),
),
"file" => note(format!(
"the body was the file `{}`; attach it here instead, as PaperBoy sends file \
bodies as form or multipart parts rather than as the whole body",
b.file.src.trim()
)),
"graphql" if b.graphql.query.trim().is_empty() => {
note("the GraphQL body held no query, so there was nothing to send".into())
}
"graphql" => {}
mode => note(format!("body mode `{mode}` was dropped")),
}
}
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
));
}
}
let scope = |owner: &Option<String>| match owner {
None => "this request's",
Some(_) => "this folder's",
};
let reach = |owner: &Option<String>| match owner {
None => "",
Some(_) => ", and it runs for every request inside",
};
for (owner, group) in owner_groups(events, "prerequest") {
let (rows, residue) = generators_from_events(&group);
let detail = if rows.is_empty() {
format!(
"{} pre-request script was dropped — Hurl cannot run one{}. If it was computing \
a nonce, a timestamp or a signature, a request's `[Gen]` block can do that \
instead",
scope(&owner),
reach(&owner)
)
} else {
let names: Vec<&str> = rows.iter().map(|(n, _)| n.as_str()).collect();
let rest = if residue {
"; the rest of it was dropped, since Hurl cannot run a script"
} else {
""
};
format!(
"{} pre-request script now computes {} in the `[Gen]` block of each request it \
covers, once per send{rest}",
scope(&owner),
and_list(&names)
)
};
push_note(out, title, owner, detail);
}
for (owner, group) in owner_groups(events, "test") {
let captures = captures_from_events(&group);
let (status, asserts, residue) = asserts_from_events(&group);
let mut kept: Vec<String> = Vec::new();
if !captures.is_empty() {
kept.push(format!("{} [Captures]", captures.len()));
}
if status.is_some() {
kept.push("the status it expects".to_string());
}
if !asserts.is_empty() {
kept.push(format!("{} [Asserts]", asserts.len()));
}
let detail = if kept.is_empty() {
format!(
"{} test script was dropped — nothing in it reduced to a Hurl capture or \
assertion",
scope(&owner)
)
} else {
let rest = if residue {
"; the rest of it was dropped"
} else {
""
};
format!(
"{} test script became {}{rest}",
scope(&owner),
and_list(&kept.iter().map(String::as_str).collect::<Vec<_>>())
)
};
push_note(out, title, owner, detail);
}
for e in events {
if e.listen != "prerequest" && e.listen != "test" {
continue;
}
let script = e
.script
.exec
.iter()
.map(|l| l.trim_end_matches('\r'))
.collect::<Vec<_>>()
.join("\n");
if !script.contains("setNextRequest") {
continue;
}
let owner = e.inherited.then(|| e.owner.clone());
for detail in next_request_fates(&script, title) {
push_note(out, title, owner.clone(), detail);
}
}
}
static NEXT_CALL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:pm\.execution|postman|pm)\.setNextRequest\s*\(").unwrap());
fn next_request_fates(script: &str, title: &str) -> Vec<String> {
let (code, in_string) = strip_js_noise(script);
let conditional = conditional_mask(&code);
let own = title.rsplit('/').next().unwrap_or(title).trim();
let mut out: Vec<String> = Vec::new();
let mut push = |d: String| {
if !out.contains(&d) {
out.push(d);
}
};
for call in find_calls(&code, &in_string, &NEXT_CALL_RE) {
let Some(arg) = call.args.first().map(|a| a.trim()) else {
continue;
};
let guarded = conditional.get(call.start).copied().unwrap_or(false)
|| !starts_statement(&code, call.start);
match unquote(arg) {
Some(name) if name.trim() == own => push(format!(
"this request ran itself again (`setNextRequest(\"{own}\")`) — that is a polling \
loop, which Hurl writes as `[Options] retry: <n>` plus the assert that has to \
pass in the end, rather than as a repeated request"
)),
Some(name) if guarded => push(format!(
"a script sometimes jumped to `{name}` instead of carrying on; PaperBoy runs a \
collection in file order and has no way to say \"only sometimes\", so check \
whether `{name}` is where it needs to be"
)),
Some(name) => push(format!(
"a script always ran `{name}` next, whatever follows this request in the file; \
move `{name}` after this request, or write the order out in a PaperTrail flow \
(`REQUEST` lines run in the order you write them)"
)),
None if arg == "null" => push(
"a script stopped the run here (`setNextRequest(null)`); nothing after this \
request ran, and in PaperBoy it will"
.into(),
),
None => push(
"a script chose the next request by a name it worked out as it ran, so what runs \
next isn't in the file at all; PaperBoy runs a collection in file order"
.into(),
),
}
}
out
}
fn push_note(out: &mut ConvertedCollection, title: &str, owner: Option<String>, detail: String) {
let item = owner.unwrap_or_else(|| title.to_string());
if out
.notes
.iter()
.any(|n| n.item == item && n.detail == detail)
{
return;
}
out.notes.push(ConversionNote { item, detail });
}
fn owner_groups(events: &[Event], listen: &str) -> Vec<(Option<String>, Vec<Event>)> {
let mut groups: Vec<(Option<String>, Vec<Event>)> = Vec::new();
for e in events.iter().filter(|e| e.listen == listen) {
let key = e.inherited.then(|| e.owner.clone());
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, group)) => group.push(e.clone()),
None => groups.push((key, vec![e.clone()])),
}
}
groups
.into_iter()
.filter(|(_, group)| has_script(group, listen))
.collect()
}
fn and_list(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => one.to_string(),
[head @ .., last] => format!("{} and {last}", head.join(", ")),
}
}
fn map_request(
name: &str,
req: &Request,
events: &[Event],
auth: Option<&Auth>,
profile: Profile,
) -> HurlEntry {
let mut headers: Vec<KvRow> = req.header.iter().filter_map(Param::enabled_kve).collect();
let mut queries: Vec<KvRow> = Vec::new();
let mut options: 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);
}
}
}
"awsv4" => {
let f = |name: &str| Auth::field(&auth.awsv4, name);
let mut provider = "aws:amz".to_string();
let (region, service) = (f("region"), f("service"));
if !region.trim().is_empty() {
provider.push(':');
provider.push_str(region.trim());
if !service.trim().is_empty() {
provider.push(':');
provider.push_str(service.trim());
}
}
options.push(KvRow::new("aws-sigv4", provider));
let key = non_empty(f("accessKey"), "{{aws_access_key_id}}");
let secret = non_empty(f("secretKey"), "{{aws_secret_access_key}}");
options.push(KvRow::new("user", format!("{key}:{secret}")));
let session = f("sessionToken");
if !session.trim().is_empty() {
headers.push(KvRow::new("x-amz-security-token", session));
}
}
_ => {}
}
}
let mut form_fields = Vec::new();
let mut body = String::new();
let pruned =
matches!(req.method.as_str(), "GET" | "HEAD") && profile.disable_body_pruning != Some(true);
if let Some(b) = &req.body.as_ref().filter(|_| !pruned) {
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(),
"graphql" if !b.graphql.query.trim().is_empty() => {
let mut doc = serde_json::Map::new();
doc.insert("query".into(), Value::String(b.graphql.query.clone()));
if let Ok(vars) = serde_json::from_str::<Value>(&b.graphql.variables)
&& !vars.is_null()
{
doc.insert("variables".into(), vars);
}
body = serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default();
if !headers
.iter()
.any(|h| h.key.eq_ignore_ascii_case("content-type"))
{
headers.push(KvRow::new("Content-Type", "application/json"));
}
}
_ => {}
}
}
let mut entry = HurlEntry::from_fields(name, &req.method, &req.url.raw, headers, &body);
entry.basic_auth = basic_auth;
entry.form_fields = form_fields;
entry.queries.extend(
req.url
.queries
.iter()
.filter(|q| q.disabled)
.filter_map(Param::enabled_kve),
);
entry.queries.extend(queries);
entry.comments.extend(
req.description
.replace("\r\n", "\n")
.lines()
.map(|line| EntryComment {
anchor: CommentAnchor::Headers,
text: if line.trim().is_empty() {
"#".to_string()
} else {
format!("# {}", line.trim_end())
},
}),
);
entry.options.extend(options);
if profile.strict_ssl == Some(false) {
entry.options.push(KvRow::new("insecure", "true"));
}
entry.captures = captures_from_events(events);
let (status, asserts, _) = asserts_from_events(events);
entry.expected_status = status;
entry.asserts = asserts;
let (generators, _) = generators_from_events(events);
entry.generators = generators;
entry
}
fn script_text(events: &[Event], listen: &str) -> String {
events
.iter()
.filter(|e| e.listen == listen)
.flat_map(|e| e.script.exec.iter())
.map(|l| l.trim_end_matches('\r'))
.collect::<Vec<_>>()
.join("\n")
}
fn has_script(events: &[Event], listen: &str) -> bool {
!script_text(events, listen).trim().is_empty()
}
static JSON_VAR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:var|let|const)\s+(\w+)\s*=\s*(?:pm\.response\.json\s*\(\s*\)|JSON\.parse\s*\(\s*responseBody\s*\))",
)
.unwrap()
});
fn captures_from_events(events: &[Event]) -> Vec<(String, String)> {
let script = script_text(events, "test");
if script.trim().is_empty() {
return Vec::new();
}
let (code, in_string) = strip_js_noise(&script);
let conditional = conditional_mask(&code);
let roots = capture_roots(&code);
let mut caps: Vec<(String, String)> = Vec::new();
for call in find_calls(&code, &in_string, &SET_CALL_RE) {
let Some((name, query)) = capture_from_call(&call, &code, &conditional, &roots) else {
continue;
};
match caps.iter_mut().find(|(n, _)| *n == name) {
Some(row) => row.1 = query,
None => caps.push((name, query)),
}
}
caps
}
fn capture_roots(code: &str) -> Vec<BodyRoot> {
let mut roots: Vec<BodyRoot> = JSON_VAR_RE
.captures_iter(code)
.map(|c| BodyRoot::whole(&c[1]))
.collect();
roots.push(BodyRoot::whole("jsonData"));
roots.push(BodyRoot::whole("pm.response.json()"));
for _ in 0..4 {
let found: Vec<BodyRoot> = ALIAS_RE
.captures_iter(code)
.filter_map(|c| {
let name = c[1].to_string();
if roots.iter().any(|r| r.name == name) {
return None;
}
if ALIAS_RE
.captures_iter(code)
.filter(|d| d[1] == name)
.count()
> 1
{
return None;
}
let prefix = accessor_to_jsonpath(&compact_code(&c[2]), &roots)?;
Some(BodyRoot { name, prefix })
})
.collect();
if found.is_empty() {
break;
}
roots.extend(found);
}
roots
}
#[derive(Debug, Clone)]
struct BodyRoot {
name: String,
prefix: String,
}
impl BodyRoot {
fn whole(name: &str) -> Self {
BodyRoot {
name: name.to_string(),
prefix: "$".to_string(),
}
}
}
static ALIAS_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:var|let|const)\s+(\w+)\s*=\s*((?:pm\.response\.json\s*\(\s*\)|[A-Za-z_$][\w$]*)(?:\s*\.\s*\w+|\s*\[[^\]\[]*\])+)",
)
.unwrap()
});
fn capture_from_call(
call: &CallSite,
code: &str,
conditional: &[bool],
roots: &[BodyRoot],
) -> Option<(String, String)> {
if call.args.len() != 2 {
return None;
}
if conditional.get(call.start).copied().unwrap_or(false) || !starts_statement(code, call.start)
{
return None;
}
let name = unquote(call.args[0].trim())?;
if !crate::hurl::is_variable_name(name) {
return None;
}
let path = accessor_to_jsonpath(&compact_code(call.args[1]), roots)?;
Some((name.to_string(), format!("jsonpath \"{path}\"")))
}
struct CallSite<'a> {
start: usize,
end: usize,
args: Vec<&'a str>,
}
fn find_calls<'a>(code: &'a str, in_string: &[bool], head: &Regex) -> Vec<CallSite<'a>> {
let mut out = Vec::new();
for m in head.find_iter(code) {
if in_string.get(m.start()).copied().unwrap_or(false) {
continue;
}
let open = m.end() - 1;
let Some(close) = matching_paren(code, open) else {
continue;
};
out.push(CallSite {
start: m.start(),
end: close + 1,
args: split_args(&code[open + 1..close]),
});
}
out
}
fn matching_paren(code: &str, open: usize) -> Option<usize> {
let mut depth = 0usize;
let mut quote: Option<char> = None;
let mut escaped = false;
for (i, c) in code[open..].char_indices() {
if let Some(q) = quote {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' => quote = Some(c),
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(open + i);
}
}
_ => {}
}
}
None
}
fn split_args(inner: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut quote: Option<char> = None;
let mut escaped = false;
let mut start = 0usize;
for (i, c) in inner.char_indices() {
if let Some(q) = quote {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' => quote = Some(c),
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
out.push(&inner[start..i]);
start = i + 1;
}
_ => {}
}
}
if !inner[start..].trim().is_empty() || !out.is_empty() {
out.push(&inner[start..]);
}
out
}
fn compact(code: &str) -> String {
code.chars().filter(|c| !c.is_whitespace()).collect()
}
fn compact_code(code: &str) -> String {
let mut out = String::with_capacity(code.len());
let mut quote: Option<char> = None;
let mut escaped = false;
for c in code.chars() {
if let Some(q) = quote {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' => {
quote = Some(c);
out.push(c);
}
_ if c.is_whitespace() => {}
_ => out.push(c),
}
}
out
}
fn has_uncovered_pm_code(code: &str, in_string: &[bool], covered: &[(usize, usize)]) -> bool {
["pm.", "postman."].iter().any(|api| {
code.match_indices(api).any(|(i, _)| {
!in_string.get(i).copied().unwrap_or(false)
&& !covered.iter().any(|(s, e)| i >= *s && i < *e)
})
})
}
static SET_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:pm\.(?:environment|collectionVariables|globals|variables)\.set|postman\.set(?:Environment|Global)Variable)\s*\(",
)
.unwrap()
});
static UUID_REQUIRE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?:var|let|const)\s+(\w+)\s*=\s*require\s*\(\s*['"]uuid['"]\s*\)"#).unwrap()
});
fn generators_from_events(events: &[Event]) -> (Vec<(String, String)>, bool) {
let script = script_text(events, "prerequest");
if script.trim().is_empty() {
return (Vec::new(), false);
}
let (code, in_string) = strip_js_noise(&script);
let conditional = conditional_mask(&code);
let mut aliases: Vec<String> = UUID_REQUIRE_RE
.captures_iter(&code)
.map(|c| c[1].to_string())
.collect();
aliases.push("uuid".to_string());
let mut covered: Vec<(usize, usize)> = UUID_REQUIRE_RE
.find_iter(&code)
.map(|m| (m.start(), m.end()))
.collect();
let mut rows: Vec<(String, String)> = Vec::new();
for call in find_calls(&code, &in_string, &SET_CALL_RE) {
if call.args.len() != 2 {
continue;
}
if conditional.get(call.start).copied().unwrap_or(false)
|| !starts_statement(&code, call.start)
{
continue;
}
let Some(name) = unquote(call.args[0].trim()) else {
continue;
};
if !crate::hurl::is_variable_name(name) {
continue;
}
let Some(expr) = gen_expression(call.args[1].trim(), &aliases) else {
continue;
};
match rows.iter_mut().find(|(n, _)| n == name) {
Some(row) => row.1 = expr,
None => rows.push((name.to_string(), expr)),
}
covered.push((call.start, call.end));
}
let residue = has_uncovered_pm_code(&code, &in_string, &covered);
(rows, residue)
}
fn gen_expression(value: &str, uuid_aliases: &[String]) -> Option<String> {
if let Some(text) = unquote(value) {
return (!text.contains(['"', '\\'])).then(|| format!("\"{text}\""));
}
if is_hurl_number(value) {
return Some(value.to_string());
}
let c = compact(value);
if uuid_aliases.iter().any(|a| c == format!("{a}.v4()"))
|| c == "require('uuid').v4()"
|| c == "require(\"uuid\").v4()"
|| c == "uuidv4()"
{
return Some("uuid".to_string());
}
match c.as_str() {
"Date.now()" | "newDate().getTime()" | "newDate().valueOf()" => {
Some("timestamp_ms".to_string())
}
"Math.floor(Date.now()/1000)"
| "Math.round(Date.now()/1000)"
| "Math.floor(newDate().getTime()/1000)"
| "Math.round(newDate().getTime()/1000)" => Some("timestamp".to_string()),
"newDate().toISOString()" => Some("iso8601".to_string()),
_ => replaced_dynamic(&c),
}
}
fn replaced_dynamic(compacted: &str) -> Option<String> {
let inner = compacted
.strip_prefix("pm.variables.replaceIn(")?
.strip_suffix(')')?;
let name = unquote(inner)?.strip_prefix("{{$")?.strip_suffix("}}")?;
match dynamic_fate(name) {
DynamicFate::Builtin("newUuid") => Some("uuid".to_string()),
DynamicFate::Builtin("newDate") => Some("iso8601".to_string()),
DynamicFate::Builtin(_) => None,
DynamicFate::Computed(expr) => Some(expr.to_string()),
DynamicFate::Supplied => None,
}
}
static EXPECT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"pm\.expect\s*\(").unwrap());
static STATUS_CALL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"pm\.response\.to\.have\.status\s*\(").unwrap());
static TEST_CALL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"pm\.test\s*\(").unwrap());
fn asserts_from_events(events: &[Event]) -> (Option<u16>, Vec<String>, bool) {
let script = script_text(events, "test");
if script.trim().is_empty() {
return (None, Vec::new(), false);
}
let (code, in_string) = strip_js_noise(&script);
let conditional = conditional_mask(&code);
let roots = capture_roots(&code);
let mut covered: Vec<(usize, usize)> = TEST_CALL_RE
.find_iter(&code)
.chain(JSON_VAR_RE.find_iter(&code))
.map(|m| (m.start(), m.end()))
.collect();
for call in find_calls(&code, &in_string, &SET_CALL_RE) {
if capture_from_call(&call, &code, &conditional, &roots).is_some() {
covered.push((call.start, call.end));
}
}
let mut status = None;
let mut asserts: Vec<String> = Vec::new();
for call in find_calls(&code, &in_string, &STATUS_CALL_RE) {
if conditional.get(call.start).copied().unwrap_or(false)
|| !starts_statement(&code, call.start)
{
continue;
}
let Some(code_num) = call.args.first().and_then(|a| a.trim().parse::<u16>().ok()) else {
continue;
};
if status.get_or_insert(code_num) == &code_num {
covered.push((call.start, call.end));
}
}
for call in find_calls(&code, &in_string, &EXPECT_RE) {
if conditional.get(call.start).copied().unwrap_or(false)
|| !starts_statement(&code, call.start)
|| call.args.len() != 1
{
continue;
}
let tail_end = statement_end(&code, call.end);
let Some(subject) = expect_subject(&compact_code(call.args[0]), &roots) else {
continue;
};
if let Subject::Json(path) = &subject
&& let Some(value) = deep_expectation(&code[call.end..tail_end])
{
let lines = crate::probe::deep_equality(path, &value);
if !lines.is_empty() {
for (s, p) in lines {
if let Some(line) = assert_line(s, p)
&& !asserts.contains(&line)
{
asserts.push(line);
}
}
covered.push((call.start, tail_end));
continue;
}
}
let Some(predicate) = parse_tail(&code[call.end..tail_end]) else {
continue;
};
match (subject, predicate) {
(Subject::Status, Predicate::Eq(v)) => match v.parse::<u16>() {
Ok(n) => {
if status.get_or_insert(n) == &n {
covered.push((call.start, tail_end));
}
continue;
}
Err(_) => continue,
},
(subject, predicate) => match assert_line(subject, predicate) {
Some(line) => {
if !asserts.contains(&line) {
asserts.push(line);
}
}
None => continue,
},
}
covered.push((call.start, tail_end));
}
let residue = has_uncovered_pm_code(&code, &in_string, &covered);
(status, asserts, residue)
}
fn expect_subject(compacted: &str, roots: &[BodyRoot]) -> Option<Subject> {
match compacted {
"pm.response.code" => return Some(Subject::Status),
"pm.response.responseTime" => return Some(Subject::Duration),
_ => {}
}
if let Some(rest) = compacted.strip_prefix("pm.response.headers.get(")
&& let Some(inner) = rest.strip_suffix(')')
&& let Some(name) = unquote(inner)
{
return Some(Subject::Header(name.to_string()));
}
if let Some(head) = compacted.strip_suffix(".length") {
return accessor_to_jsonpath(head, roots).map(Subject::JsonCount);
}
accessor_to_jsonpath(compacted, roots).map(Subject::Json)
}
fn deep_expectation(tail: &str) -> Option<Value> {
let (head, inner) = split_chai_call(tail)?;
matches!(
head.as_str(),
".to.eql(" | ".to.deep.equal(" | ".to.deep.eql("
)
.then(|| js_literal(inner))
.flatten()
}
fn split_chai_call(tail: &str) -> Option<(String, &str)> {
let t = tail.trim().trim_end_matches(';').trim_end();
let inner = t.strip_suffix(')')?;
let open = inner.find('(')?;
Some((compact(&inner[..=open]), &inner[open + 1..]))
}
fn parse_tail(tail: &str) -> Option<Predicate> {
let calls: [(&str, fn(String) -> Predicate); 12] = [
(".to.not.be.equal(", Predicate::Ne),
(".to.not.equal(", Predicate::Ne),
(".to.not.eql(", Predicate::Ne),
(".to.deep.equal(", Predicate::Eq),
(".to.deep.eql(", Predicate::Eq),
(".to.be.equal(", Predicate::Eq),
(".to.equal(", Predicate::Eq),
(".to.eql(", Predicate::Eq),
(".to.include(", Predicate::Contains),
(".to.contain(", Predicate::Contains),
(".to.be.above(", Predicate::Gt),
(".to.be.below(", Predicate::Lt),
];
if let Some((call, inner)) = split_chai_call(tail) {
for (head, make) in calls {
if call == head {
return hurl_literal(inner).map(make);
}
}
}
match compact(tail).trim_end_matches(';') {
".to.be.empty" | ".is.empty" => Some(Predicate::Empty(true)),
".to.not.be.empty" | ".is.not.empty" => Some(Predicate::Empty(false)),
_ => None,
}
}
fn hurl_literal(value: &str) -> Option<String> {
let value = value.trim();
if let Some(text) = unquote(value) {
return (!text.contains(['"', '\\']) && !text.contains("{{"))
.then(|| format!("\"{text}\""));
}
if matches!(value, "true" | "false" | "null") {
return Some(value.to_string());
}
is_hurl_number(value).then(|| value.to_string())
}
fn is_hurl_number(value: &str) -> bool {
let digits = value.strip_prefix('-').unwrap_or(value);
let mut parts = digits.splitn(2, '.');
let int = parts.next().unwrap_or("");
if int.is_empty() || !int.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
match parts.next() {
None => true,
Some(frac) => !frac.is_empty() && frac.bytes().all(|b| b.is_ascii_digit()),
}
}
fn starts_statement(code: &str, start: usize) -> bool {
let boundary = code[..start].rfind([';', '{', '}', '\n']);
let from = boundary.map_or(0, |i| i + 1);
if !code[from..start].trim().is_empty() {
return false;
}
if boundary.is_some_and(|i| code.as_bytes()[i] == b'\n') {
let head = code[..boundary.unwrap()].trim_end();
if ends_with_guard_head(head) {
return false;
}
}
true
}
fn ends_with_guard_head(head: &str) -> bool {
let word_before = |s: &str| -> String {
s.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
};
let last_word = word_before(head);
if matches!(last_word.as_str(), "else" | "do") {
return true;
}
if head.ends_with(')')
&& let Some(open) = matching_open_paren(head)
{
let keyword = word_before(head[..open].trim_end());
return matches!(keyword.as_str(), "if" | "for" | "while");
}
false
}
fn matching_open_paren(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.last() != Some(&b')') {
return None;
}
let mut depth = 0i32;
for i in (0..bytes.len()).rev() {
match bytes[i] {
b')' => depth += 1,
b'(' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
fn statement_end(code: &str, from: usize) -> usize {
let mut depth = 0i32;
let mut quote: Option<char> = None;
let mut escaped = false;
for (i, c) in code[from..].char_indices() {
if let Some(q) = quote {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' => quote = Some(c),
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' if depth > 0 => depth -= 1,
';' | '\n' if depth == 0 => return from + i,
')' | '}' if depth == 0 => return from + i,
_ => {}
}
}
code.len()
}
fn conditional_mask(code: &str) -> Vec<bool> {
let mut mask = vec![false; code.len()];
let mut blocks: Vec<bool> = Vec::new();
let mut parens: Vec<usize> = Vec::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for (i, c) in code.char_indices() {
let inside = blocks.iter().any(|c| *c);
for m in mask.iter_mut().skip(i).take(c.len_utf8()) {
*m = inside;
}
if let Some(q) = quote {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == q {
quote = None;
}
continue;
}
match c {
'\'' | '"' | '`' => quote = Some(c),
'(' => parens.push(i),
')' => {
parens.pop();
}
'{' => blocks.push(!opens_test_callback(code, &parens, i)),
'}' => {
blocks.pop();
}
_ => {}
}
}
mask
}
fn opens_test_callback(code: &str, parens: &[usize], at: usize) -> bool {
let Some(&open) = parens.last() else {
return false;
};
let name: String = code[..open]
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if !matches!(name.as_str(), "pm.test" | "pm.it") {
return false;
}
static TEST_CALLBACK_HEADER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\(.*,(?:async)?function\*?\([^)]*\)$").unwrap());
let between = compact(&code[open..at]);
between.ends_with("=>") || TEST_CALLBACK_HEADER.is_match(&between)
}
fn strip_js_noise(script: &str) -> (String, Vec<bool>) {
#[derive(PartialEq)]
enum St {
Code,
Line,
Block,
Str(char),
Regex,
}
let mut out = String::with_capacity(script.len());
let mut in_string = Vec::with_capacity(script.len());
let mut st = St::Code;
let mut escaped = false;
let mut regex_class = false;
let mut chars = script.chars().peekable();
while let Some(c) = chars.next() {
let (keep, quoted) = match st {
St::Code => match c {
'/' if chars.peek() == Some(&'/') => {
st = St::Line;
(false, false)
}
'/' if chars.peek() == Some(&'*') => {
st = St::Block;
(false, false)
}
'/' if regex_position(&out, &in_string) => {
st = St::Regex;
escaped = false;
regex_class = false;
(false, false)
}
'\'' | '"' | '`' => {
st = St::Str(c);
escaped = false;
(true, true)
}
_ => (true, false),
},
St::Line => {
if c == '\n' {
st = St::Code;
(true, false)
} else {
(false, false)
}
}
St::Block => {
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
out.push(' ');
in_string.push(false);
st = St::Code;
}
(c == '\n', false)
}
St::Str(delim) => {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == delim || (c == '\n' && delim != '`') {
st = St::Code;
}
(true, true)
}
St::Regex => {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '[' {
regex_class = true;
} else if c == ']' {
regex_class = false;
} else if c == '\n' {
st = St::Code;
} else if c == '/' && !regex_class {
while chars.peek().is_some_and(|p| p.is_ascii_alphabetic()) {
chars.next();
out.push(' ');
in_string.push(false);
}
st = St::Code;
}
(false, false)
}
};
let ch = if keep { c } else { ' ' };
out.push(ch);
for _ in 0..ch.len_utf8() {
in_string.push(quoted);
}
}
debug_assert_eq!(out.len(), in_string.len());
(out, in_string)
}
fn regex_position(out: &str, in_string: &[bool]) -> bool {
let last = out
.char_indices()
.rev()
.find(|&(i, ch)| !ch.is_whitespace() && !in_string.get(i).copied().unwrap_or(false))
.map(|(_, ch)| ch);
match last {
None => true,
Some(c) if "(,=:[!&|{;?".contains(c) => true,
Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => {
let word: String = out
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
matches!(
word.as_str(),
"return"
| "typeof"
| "instanceof"
| "in"
| "of"
| "do"
| "else"
| "case"
| "void"
| "delete"
| "new"
| "throw"
| "yield"
| "await"
)
}
Some(_) => false,
}
}
fn accessor_to_jsonpath(expr: &str, roots: &[BodyRoot]) -> Option<String> {
let mut by_len: Vec<&BodyRoot> = roots.iter().collect();
by_len.sort_by_key(|r| std::cmp::Reverse(r.name.len()));
let (root, mut s) = by_len.iter().find_map(|r| {
expr.strip_prefix(r.name.as_str())
.filter(|rest| rest.is_empty() || rest.starts_with(['.', '[']))
.map(|rest| (*r, rest))
})?;
let mut path = root.prefix.clone();
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 js_literal(src: &str) -> Option<Value> {
let src = src.trim();
if !(src.starts_with('{') || src.starts_with('[')) {
return None;
}
let mut out = String::with_capacity(src.len());
let mut chars = src.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match c {
'"' | '\'' => {
let str_start = out.len();
out.push('"');
let mut closed = false;
while let Some((_, d)) = chars.next() {
match d {
'\\' => {
let (_, e) = chars.next()?;
match e {
'\'' => out.push('\''),
'"' => out.push_str("\\\""),
other => {
out.push('\\');
out.push(other);
}
}
}
d if d == c => {
closed = true;
break;
}
'"' => out.push_str("\\\""),
'\n' => return None,
other => out.push(other),
}
}
if !closed {
return None;
}
out.push('"');
if out[str_start..].contains("{{") {
return None;
}
}
c if c.is_ascii_alphabetic() || c == '_' || c == '$' => {
let start = i;
let mut end = i + c.len_utf8();
while let Some(&(j, d)) = chars.peek() {
if d.is_ascii_alphanumeric() || d == '_' || d == '$' {
end = j + d.len_utf8();
chars.next();
} else {
break;
}
}
let word = &src[start..end];
let followed_by_colon = src[end..].trim_start().starts_with(':');
match word {
"true" | "false" | "null" if !followed_by_colon => out.push_str(word),
_ if followed_by_colon => {
out.push('"');
out.push_str(word);
out.push('"');
}
_ => return None,
}
}
',' => {
if src[i + 1..].trim_start().starts_with(['}', ']']) {
continue;
}
out.push(',');
}
other => out.push(other),
}
}
serde_json::from_str(&out).ok()
}
fn unquote(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
let quote = *bytes.first()?;
if quote != b'\'' && quote != b'"' {
return None;
}
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate().skip(1) {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == quote {
return (i == bytes.len() - 1).then(|| &s[1..i]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
mod shipped_examples {
use super::*;
use std::collections::HashMap;
fn convert_example(file: &str) -> ConvertedCollection {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/postman/");
let json = std::fs::read_to_string(format!("{path}{file}"))
.unwrap_or_else(|e| panic!("{file} is shipped and must be readable: {e}"));
convert_postman(&json)
}
fn entry<'a>(c: &'a ConvertedCollection, title: &str) -> &'a HurlEntry {
c.entries
.iter()
.find(|e| e.title == title)
.unwrap_or_else(|| {
panic!(
"no request titled {title:?}; found {:?}",
c.entries.iter().map(|e| &e.title).collect::<Vec<_>>()
)
})
}
#[test]
fn the_dynamic_variable_example_demonstrates_all_three_outcomes() {
let c = convert_example("dynamic-variables.postman_collection.json");
let builtin = entry(&c, "Built in/A GUID and an ISO timestamp");
let text = builtin.to_hurl();
assert!(
text.contains("{{newUuid}}") && text.contains("{{newDate}}"),
"$guid and $isoTimestamp become Hurl's own placeholders: {text}"
);
assert!(
builtin.generators.is_empty(),
"a built-in needs no computed row"
);
let twice = entry(&c, "Built in/The same GUID twice");
assert!(
twice.generators.is_empty(),
"and still none when used in two places"
);
let computed = entry(&c, "Computed/A Unix timestamp and a random integer");
let names: Vec<&str> = computed
.generators
.iter()
.map(|(n, _)| n.as_str())
.collect();
assert_eq!(
computed.generators.len(),
2,
"one row per name however often it is used, not one per use: {names:?}"
);
let exprs: Vec<&str> = computed
.generators
.iter()
.map(|(_, e)| e.as_str())
.collect();
assert!(
exprs.contains(&"timestamp"),
"$timestamp is Unix seconds: {exprs:?}"
);
assert!(
exprs.iter().any(|e| e.starts_with("random_int(")),
"$randomInt is a bounded integer: {exprs:?}"
);
let supplied = entry(&c, "Supplied/Faker data nothing can produce");
assert!(
supplied.generators.is_empty(),
"nothing here can be honestly computed"
);
assert!(
c.notes.iter().any(|n| n.item == supplied.title),
"so the user is told to supply it instead"
);
}
#[test]
fn every_shipped_example_still_parses_as_hurl() {
for file in [
"dynamic-variables.postman_collection.json",
"signed-requests.postman_collection.json",
] {
let c = convert_example(file);
assert!(!c.entries.is_empty(), "{file} converted to nothing");
for e in &c.entries {
let text = e.to_hurl();
let back = crate::hurl::parse_hurl(&text);
assert_eq!(
back.len(),
1,
"{file}: {:?} did not survive a round trip:\n{text}",
e.title
);
assert_eq!(back[0].generators, e.generators, "{file}: {:?}", e.title);
}
}
}
#[test]
fn the_worked_signing_example_parses_and_evaluates() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/postman/signed-requests.hurl"
);
let text = std::fs::read_to_string(path).expect("shipped example must be readable");
let entries = crate::hurl::parse_hurl(&text);
assert_eq!(entries.len(), 6, "six requests, one deliberately broken");
let vars: HashMap<String, String> = [
("baseUrl", "https://postman-echo.com"),
("API_KEY", "EXAMPLE-KEY-id"),
("API_SECRET", "EXAMPLE-SECRET-not-a-real-key"),
("SINCE", "2026-01-01T00:00:00Z"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
for e in &entries {
assert!(
!e.generators.is_empty(),
"{:?} is in this file to demonstrate a block",
e.title
);
let mut merged = vars.clone();
let errors = crate::generators::expand(
&e.generators,
&mut merged,
&crate::generators::SystemSource::new(),
);
if e.title.starts_with("Deliberately broken") {
assert_eq!(errors.len(), 1, "the typo is the point of that request");
continue;
}
assert!(errors.is_empty(), "{:?}: {errors:?}", e.title);
}
let vector = &entries[4];
let mut merged = vars.clone();
crate::generators::expand(
&vector.generators,
&mut merged,
&crate::generators::SystemSource::new(),
);
assert_eq!(
merged.get("sig").map(String::as_str),
Some("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"),
"RFC 4231 case 2"
);
}
#[test]
fn the_signing_example_keeps_the_scripts_it_cannot_run() {
let c = convert_example("signed-requests.postman_collection.json");
let signed = entry(&c, "HMAC-SHA256 over a nonce and a timestamp");
assert!(
signed.to_hurl().contains("{{sig}}"),
"the signature placeholder is preserved for the [Gen] row to fill"
);
assert!(
c.notes
.iter()
.any(|n| n.item == signed.title && n.detail.to_lowercase().contains("script")),
"and the dropped pre-request script is reported, not silently lost: {:?}",
c.notes
);
}
}
#[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_src.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![BodyRoot::whole("jsonData")];
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": "POST",
"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 description_tests {
use super::*;
fn one(request: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{ "info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {{ "name": "r", "request": {request} }} ] }}"#
))
}
fn comments(e: &HurlEntry) -> Vec<&str> {
e.comments.iter().map(|c| c.text.as_str()).collect()
}
#[test]
fn a_request_description_is_kept_as_comments() {
let c = one(r#"{ "method": "GET", "url": "https://h/x",
"description": "Returns the current user.\n\nRequires the `read` scope." }"#);
assert_eq!(
comments(&c.entries[0]),
vec![
"# Returns the current user.",
"#",
"# Requires the `read` scope."
]
);
}
#[test]
fn the_description_never_becomes_part_of_the_name() {
let c = one(r#"{ "method": "GET", "url": "https://h/x", "description": "long prose" }"#);
assert_eq!(c.entries[0].title, "r");
}
#[test]
fn an_object_description_is_read_too() {
let c = one(r##"{ "method": "GET", "url": "https://h/x",
"description": { "content": "Heading", "type": "text/markdown" } }"##);
assert_eq!(comments(&c.entries[0]), vec!["# Heading"]);
}
#[test]
fn no_description_adds_nothing() {
let c = one(r#"{ "method": "GET", "url": "https://h/x" }"#);
assert!(c.entries[0].comments.is_empty());
}
#[test]
fn a_null_description_is_survivable() {
let c = one(r#"{ "method": "GET", "url": "https://h/x", "description": null }"#);
assert_eq!(c.entries.len(), 1);
assert!(c.entries[0].comments.is_empty());
}
}
#[cfg(test)]
mod body_mode_tests {
use super::*;
fn post(body: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{ "info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {{ "name": "r", "request": {{ "method": "POST",
"url": "https://h/x", "body": {body} }} }} ] }}"#
))
}
#[test]
fn a_graphql_body_becomes_the_json_post_it_actually_is() {
let c = post(
r#"{ "mode": "graphql", "graphql": {
"query": "query Q($id: ID){ thing(id: $id) }",
"variables": "{ \"id\": \"7\" }" } }"#,
);
let e = &c.entries[0];
let sent: serde_json::Value = serde_json::from_str(e.body_src.as_deref().unwrap()).unwrap();
assert_eq!(sent["query"], "query Q($id: ID){ thing(id: $id) }");
assert_eq!(
sent["variables"]["id"], "7",
"the variables nest as an object, not as the string Postman stores"
);
assert!(
e.headers
.iter()
.any(|h| h.key.eq_ignore_ascii_case("content-type")
&& h.value.contains("application/json"))
);
assert!(
!c.notes.iter().any(|n| n.detail.contains("graphql")),
"and nothing was lost to report: {:?}",
c.notes
);
}
#[test]
fn unparseable_graphql_variables_are_left_out() {
let c = post(
r#"{ "mode": "graphql", "graphql": { "query": "{ ping }",
"variables": "{ not json" } }"#,
);
let sent: serde_json::Value =
serde_json::from_str(c.entries[0].body_src.as_deref().unwrap()).unwrap();
assert_eq!(sent["query"], "{ ping }");
assert!(sent.get("variables").is_none());
}
#[test]
fn a_file_body_is_reported_rather_than_imported_and_lost() {
let c = post(r#"{ "mode": "file", "file": { "src": "./payload.bin" } }"#);
assert_eq!(c.entries[0].body_src, None);
assert!(
c.notes.iter().any(|n| n.detail.contains("./payload.bin")),
"the path is named so it can be attached by hand: {:?}",
c.notes
);
}
#[test]
fn a_file_body_with_no_file_is_reported_not_invented() {
let c = post(r#"{ "mode": "file", "file": { "src": "" } }"#);
assert_eq!(c.entries[0].body_src, None);
assert!(
c.notes
.iter()
.any(|n| n.detail.contains("never had one chosen")),
"{:?}",
c.notes
);
}
}
#[cfg(test)]
mod profile_behavior_tests {
use super::*;
fn convert(item: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{ "info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {item} ] }}"#
))
}
#[test]
fn a_get_body_postman_would_not_have_sent_is_left_out() {
let c = convert(
r#"{ "name": "r", "request": { "method": "GET", "url": "https://h/x",
"body": { "mode": "raw", "raw": "{\"stale\":true}" } } }"#,
);
assert_eq!(c.entries[0].body_src, None);
assert!(
c.notes.iter().any(|n| n.detail.contains("body pruning")),
"and it says why, since the text is visibly in the export: {:?}",
c.notes
);
}
#[test]
fn disable_body_pruning_keeps_the_body() {
let c = convert(
r#"{ "name": "r", "protocolProfileBehavior": { "disableBodyPruning": true },
"request": { "method": "GET", "url": "https://h/x",
"body": { "mode": "raw", "raw": "{}" } } }"#,
);
assert_eq!(c.entries[0].body_src.as_deref(), Some("{}"));
}
#[test]
fn a_post_body_is_untouched() {
let c = convert(
r#"{ "name": "r", "request": { "method": "POST", "url": "https://h/x",
"body": { "mode": "raw", "raw": "{}" } } }"#,
);
assert_eq!(c.entries[0].body_src.as_deref(), Some("{}"));
}
#[test]
fn a_folder_can_turn_pruning_off_for_everything_inside_it() {
let c = convert(
r#"{ "name": "F", "protocolProfileBehavior": { "disableBodyPruning": true },
"item": [ { "name": "r", "request": { "method": "GET", "url": "https://h/x",
"body": { "mode": "raw", "raw": "{}" } } } ] }"#,
);
assert_eq!(c.entries[0].body_src.as_deref(), Some("{}"));
}
#[test]
fn strict_ssl_off_becomes_the_insecure_option() {
let c = convert(
r#"{ "name": "r", "protocolProfileBehavior": { "strictSSL": false },
"request": { "method": "GET", "url": "https://h/x" } }"#,
);
assert!(
c.entries[0]
.options
.contains(&KvRow::toggled("insecure", "true", true))
);
}
#[test]
fn certificate_checking_stays_on_by_default() {
let c = convert(r#"{ "name": "r", "request": { "method": "GET", "url": "https://h/x" } }"#);
assert!(c.entries[0].options.is_empty());
}
#[test]
fn a_disabled_query_parameter_imports_switched_off() {
let c = convert(
r#"{ "name": "r", "request": { "method": "GET", "url": {
"raw": "https://h/x?page=2",
"query": [ { "key": "page", "value": "2" },
{ "key": "verbose", "value": "true", "disabled": true } ] } } }"#,
);
let q = &c.entries[0].queries;
assert!(
q.contains(&KvRow::toggled("verbose", "true", false)),
"the disabled parameter is kept, switched off: {q:?}"
);
assert_eq!(
q.iter().filter(|r| r.key == "page").count(),
0,
"and the enabled one is not duplicated — it is already in the URL text"
);
assert_eq!(c.entries[0].url, "https://h/x?page=2");
}
}
#[cfg(test)]
mod awsv4_tests {
use super::*;
fn one(auth: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{
"info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {{ "name": "r", "request": {{ "method": "GET",
"url": "https://api.example.com/v1/x", "auth": {auth} }} }} ]
}}"#
))
}
fn option(e: &HurlEntry, name: &str) -> Option<String> {
e.options
.iter()
.find(|o| o.key == name)
.map(|o| o.value.clone())
}
#[test]
fn awsv4_auth_becomes_the_aws_sigv4_option() {
let c = one(r#"{ "type": "awsv4", "awsv4": [
{ "key": "accessKey", "value": "AKIA1" },
{ "key": "secretKey", "value": "s3cret" },
{ "key": "region", "value": "eu-west-1" },
{ "key": "service", "value": "execute-api" } ] }"#);
let e = &c.entries[0];
assert_eq!(
option(e, "aws-sigv4").as_deref(),
Some("aws:amz:eu-west-1:execute-api")
);
assert_eq!(option(e, "user").as_deref(), Some("AKIA1:s3cret"));
}
#[test]
fn an_unnamed_region_is_left_for_curl_to_infer() {
let c = one(r#"{ "type": "awsv4", "awsv4": [
{ "key": "accessKey", "value": "AKIA1" },
{ "key": "secretKey", "value": "s3cret" } ] }"#);
assert_eq!(
option(&c.entries[0], "aws-sigv4").as_deref(),
Some("aws:amz")
);
}
#[test]
fn a_bare_aws_auth_block_signs_with_named_variables_and_says_so() {
let c = one(r#"{ "type": "awsv4" }"#);
assert_eq!(
option(&c.entries[0], "user").as_deref(),
Some("{{aws_access_key_id}}:{{aws_secret_access_key}}")
);
assert!(
c.notes
.iter()
.any(|n| n.detail.contains("aws_access_key_id")),
"the user is told where to put the keys: {:?}",
c.notes
);
assert!(
!c.notes.iter().any(|n| n.detail.contains("was dropped")),
"and it is no longer reported as a lost auth type"
);
}
#[test]
fn a_session_token_rides_in_its_own_header() {
let c = one(r#"{ "type": "awsv4", "awsv4": [
{ "key": "accessKey", "value": "A" },
{ "key": "secretKey", "value": "B" },
{ "key": "sessionToken", "value": "tok" } ] }"#);
assert!(c.entries[0].headers.contains(&KvRow::toggled(
"x-amz-security-token",
"tok",
true
)));
}
}
#[cfg(test)]
mod oauth2_tests {
use super::*;
fn folder_oauth2(extra: &str) -> String {
format!(
r#"{{
"info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [
{{ "name": "Tenant API",
"auth": {{ "type": "oauth2", "oauth2": [
{{ "key": "accessTokenUrl", "value": "https://id.example.com/v1/token" }},
{{ "key": "grant_type", "value": "client_credentials" }},
{{ "key": "clientId", "value": "abc" }},
{{ "key": "clientSecret", "value": "shh" }},
{{ "key": "scope", "value": "read write" }},
{{ "key": "tokenType", "value": "Bearer" }}
{extra}
] }},
"item": [
{{ "name": "list", "request": {{ "method": "GET", "url": "https://h/a" }} }},
{{ "name": "get", "request": {{ "method": "GET", "url": "https://h/b" }} }}
] }}
]
}}"#
)
}
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())
}
fn field(e: &HurlEntry, key: &str) -> Option<String> {
e.form_fields
.iter()
.find(|f| f.key == key)
.map(|f| f.value.clone())
}
#[test]
fn a_folders_client_credentials_auth_becomes_a_token_request() {
let c = convert_postman(&folder_oauth2(""));
let titles: Vec<&str> = c.entries.iter().map(|e| e.title.as_str()).collect();
assert_eq!(
titles,
vec![
"Tenant API/Get access token",
"Tenant API/list",
"Tenant API/get"
],
"the token request is generated once, in the folder that declared \
the auth, ahead of the requests that need it"
);
let token = &c.entries[0];
assert_eq!(token.method, "POST");
assert_eq!(token.url, "https://id.example.com/v1/token");
assert_eq!(
field(token, "grant_type").as_deref(),
Some("client_credentials")
);
assert_eq!(field(token, "scope").as_deref(), Some("read write"));
assert_eq!(
token.basic_auth,
Some(("abc".to_string(), "shh".to_string())),
"Postman's default client authentication is HTTP Basic"
);
assert_eq!(
token.captures,
vec![(
"access_token".to_string(),
"jsonpath \"$.access_token\"".to_string()
)]
);
assert_eq!(
token.expected_status,
Some(200),
"without this a failed token request captures nothing and every \
request after it fails for a reason that has scrolled away"
);
for e in &c.entries[1..] {
assert_eq!(
header(e, "Authorization").as_deref(),
Some("Bearer {{access_token}}"),
"{} must actually use the token",
e.title
);
}
}
#[test]
fn one_token_request_is_generated_per_distinct_configuration() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "A", "auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "same" } ] },
"item": [ { "name": "x", "request": { "method": "GET", "url": "https://h/x" } } ] },
{ "name": "B", "auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "same" } ] },
"item": [ { "name": "y", "request": { "method": "GET", "url": "https://h/y" } } ] },
{ "name": "C", "auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "other" } ] },
"item": [ { "name": "z", "request": { "method": "GET", "url": "https://h/z" } } ] }
]
}"#,
);
let tokens: Vec<&str> = c
.entries
.iter()
.filter(|e| e.title.ends_with("Get access token"))
.map(|e| e.title.as_str())
.collect();
assert_eq!(
tokens,
vec!["A/Get access token", "C/Get access token"],
"identical configurations share a token; a different client gets its own"
);
let used = |title: &str| {
c.entries
.iter()
.find(|e| e.title == title)
.and_then(|e| header(e, "Authorization"))
};
assert_eq!(used("A/x"), used("B/y"), "B reuses A's token");
assert_eq!(used("C/z").as_deref(), Some("Bearer {{access_token_2}}"));
}
#[test]
fn body_client_authentication_sends_the_credentials_as_form_fields() {
let c = convert_postman(&folder_oauth2(
r#", { "key": "client_authentication", "value": "body" }"#,
));
let token = &c.entries[0];
assert_eq!(token.basic_auth, None);
assert_eq!(field(token, "client_id").as_deref(), Some("abc"));
assert_eq!(field(token, "client_secret").as_deref(), Some("shh"));
}
#[test]
fn add_token_to_query_params_uses_a_query_parameter() {
let c = convert_postman(&folder_oauth2(
r#", { "key": "addTokenTo", "value": "queryParams" }"#,
));
let list = c
.entries
.iter()
.find(|e| e.title == "Tenant API/list")
.unwrap();
assert_eq!(header(list, "Authorization"), None);
assert!(
list.queries
.contains(&KvRow::toggled("access_token", "{{access_token}}", true))
);
}
#[test]
fn the_authorization_code_grant_is_reported_not_invented() {
let c =
convert_postman(&folder_oauth2("").replace("client_credentials", "authorization_code"));
assert!(
c.entries
.iter()
.all(|e| !e.title.ends_with("Get access token")),
"nothing is generated for a flow that needs a human"
);
assert!(
c.notes
.iter()
.any(|n| n.detail.contains("authorization_code")),
"but the user is told why: {:?}",
c.notes
);
}
#[test]
fn missing_credentials_become_variables_and_a_note() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [ { "name": "x", "request": { "method": "GET", "url": "https://h/x",
"auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "" },
{ "key": "clientSecret", "value": "" } ] } } } ]
}"#,
);
assert_eq!(
c.entries[0].basic_auth,
Some((
"{{oauth_client_id}}".to_string(),
"{{oauth_client_secret}}".to_string()
))
);
assert!(
c.notes.iter().any(|n| n.detail.contains("oauth_client_id")),
"and it says so rather than leaving a silently unusable request"
);
}
#[test]
fn an_override_with_no_token_url_reuses_the_inherited_token() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "abc" } ] },
"item": [
{ "name": "plain", "request": { "method": "GET", "url": "https://h/a" } },
{ "name": "F", "auth": { "type": "oauth2", "oauth2": [
{ "key": "headerPrefix", "value": "Token " } ] },
"item": [ { "name": "y", "request": { "method": "GET", "url": "https://h/y" } } ] }
]
}"#,
);
assert_eq!(
c.entries
.iter()
.filter(|e| e.title.ends_with("Get access token"))
.count(),
1,
"the override has no token URL of its own to fetch from"
);
let y = c.entries.iter().find(|e| e.title == "F/y").unwrap();
assert_eq!(
header(y, "Authorization").as_deref(),
Some("Token {{access_token}}"),
"but its prefix override is honoured"
);
}
#[test]
fn a_collection_wide_token_is_not_buried_in_a_folder() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "client_credentials" },
{ "key": "clientId", "value": "abc" } ] },
"item": [ { "name": "Deep", "item": [ { "name": "Deeper", "item": [
{ "name": "x", "request": { "method": "GET", "url": "https://h/x" } } ] } ] } ]
}"#,
);
assert_eq!(c.entries[0].title, "Get access token");
}
#[test]
fn the_password_grant_is_generated_like_client_credentials() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [ { "name": "x", "request": { "method": "GET", "url": "https://h/x",
"auth": { "type": "oauth2", "oauth2": [
{ "key": "accessTokenUrl", "value": "https://id/t" },
{ "key": "grant_type", "value": "password" },
{ "key": "clientId", "value": "abc" },
{ "key": "username", "value": "u" },
{ "key": "password", "value": "p" } ] } } } ]
}"#,
);
let token = &c.entries[0];
assert_eq!(field(token, "grant_type").as_deref(), Some("password"));
assert_eq!(field(token, "username").as_deref(), Some("u"));
assert_eq!(field(token, "password").as_deref(), Some("p"));
}
}
#[cfg(test)]
mod path_variable_tests {
use super::*;
fn one(url: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{
"info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {{ "name": "r", "request": {{ "method": "GET", "url": {url} }} }} ]
}}"#
))
}
#[test]
fn a_declared_path_variable_becomes_a_hurl_variable() {
let c = one(r#"{ "raw": "{{base}}/v1/batches/:batch_id/add",
"variable": [{ "key": "batch_id", "value": "se-28529731" }] }"#);
assert_eq!(c.entries[0].url, "{{base}}/v1/batches/{{batch_id}}/add");
assert!(
c.variables
.contains(&("batch_id".into(), "se-28529731".into())),
"and the value Postman would have substituted comes with it"
);
}
#[test]
fn an_undeclared_colon_segment_is_left_alone() {
let c = one(r#"{ "raw": "http://localhost:8080/v1/:not_declared" }"#);
assert_eq!(c.entries[0].url, "http://localhost:8080/v1/:not_declared");
assert!(c.variables.is_empty());
}
#[test]
fn a_port_is_never_mistaken_for_a_path_variable() {
let c = one(r#"{ "raw": "http://host:8080/x/:id",
"variable": [{ "key": "id", "value": "7" }] }"#);
assert_eq!(c.entries[0].url, "http://host:8080/x/{{id}}");
}
#[test]
fn the_query_string_is_not_rewritten() {
let c = one(r#"{ "raw": "https://h/x/:id?at=12:30&who=:id",
"variable": [{ "key": "id", "value": "7" }] }"#);
assert_eq!(
c.entries[0].url, "https://h/x/{{id}}?at=12:30&who=:id",
"the path placeholder is rewritten; the colons after the `?` are not"
);
}
#[test]
fn two_requests_declaring_the_same_name_differently_are_reported() {
let c = convert_postman(
r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "a", "request": { "method": "GET", "url": {
"raw": "https://h/:id", "variable": [{ "key": "id", "value": "1" }] } } },
{ "name": "b", "request": { "method": "GET", "url": {
"raw": "https://h/:id", "variable": [{ "key": "id", "value": "2" }] } } }
]
}"#,
);
assert_eq!(c.variables, vec![("id".to_string(), "1".to_string())]);
assert!(
c.notes
.iter()
.any(|n| n.item == "b" && n.detail.contains("id")),
"the discarded second value is named, not swallowed: {:?}",
c.notes
);
}
#[test]
fn a_declared_variable_with_no_value_still_parameterises_the_url() {
let c = one(r#"{ "raw": "{{baseUrl}}/v1/batches/:batch_id",
"variable": [{ "key": "batch_id", "value": "", "description": "Batch ID" }] }"#);
assert_eq!(c.entries[0].url, "{{baseUrl}}/v1/batches/{{batch_id}}");
assert_eq!(c.variables, vec![("batch_id".to_string(), String::new())]);
}
}
#[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": "oauth1", "request": { "method": "GET", "url": "https://x",
"auth": { "type": "oauth1" } } },
{ "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("oauth1")[0].contains("oauth1"),
"the auth type that was lost is named: {notes:?}"
);
assert!(
for_item("gql")[0].contains("GraphQL"),
"an empty GraphQL body has nothing to send, and says so"
);
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("{{newUuid}}"),
"a GUID is something Hurl generates itself: {hurl}"
);
assert!(hurl.contains("{{timestamp}}"), "{hurl}");
assert!(
hurl.contains("# [Gen] 1") && hurl.contains("# timestamp = timestamp"),
"a Unix timestamp is computed rather than left to be supplied: {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("{{newUuid}}") && n.detail.contains("nothing supplied")),
"the GUID note says it needs nothing: {:?}",
converted.notes
);
assert!(
converted
.notes
.iter()
.any(|n| n.detail.contains("{{processEnv_HOME}}")
&& n.detail.contains("has to be supplied")),
"the one nothing can produce still says so: {:?}",
converted.notes
);
}
#[test]
fn the_generated_values_paperboy_can_produce_are_produced() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "start", "request": { "method": "POST",
"url": "https://x/?n={{$randomInt}}&u={{$randomUUID}}",
"header": [ { "key": "X-At", "value": "{{$isoTimestamp}}" } ] } } ]
}"#;
let converted = convert_postman(json);
let entry = &converted.entries[0];
assert_eq!(
entry.generators,
vec![("randomInt".to_string(), "random_int(0, 1000)".to_string())],
"only the one Hurl can't generate itself needs a row"
);
assert!(entry.url.contains("{{newUuid}}"), "{}", entry.url);
assert_eq!(entry.headers[0].value, "{{newDate}}");
let hurl = crate::hurl::collection_to_hurl(&converted.entries);
let back = crate::hurl::parse_hurl(&hurl);
assert_eq!(back.len(), 1, "{:?}", crate::hurl::parse_hurl_error(&hurl));
assert_eq!(back[0].generators, entry.generators, "the block survives");
}
#[test]
fn a_generated_value_used_twice_declares_one_row() {
let json = r#"{
"info": { "name": "d", "schema": "x" },
"item": [ { "name": "start", "request": { "method": "POST",
"url": "https://x/?a={{$timestamp}}&b={{$timestamp}}",
"header": [ { "key": "X-At", "value": "{{$timestamp}}" } ] } } ]
}"#;
let converted = convert_postman(json);
assert_eq!(
converted.entries[0].generators,
vec![("timestamp".to_string(), "timestamp".to_string())]
);
assert_eq!(
converted.notes.len(),
1,
"and it is reported once: {:?}",
converted.notes
);
}
#[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![]);
}
}
#[cfg(test)]
mod field_tolerance_tests {
use super::*;
fn collection_with(param: &str) -> String {
format!(
r#"{{
"info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"auth": {{ "type": "oauth2", "oauth2": [
{{ "key": "accessTokenUrl", "value": "https://id.example.com/token" }},
{{ "key": "grant_type", "value": "client_credentials" }},
{{ "key": "clientId", "value": "abc" }},
{{ "key": "clientSecret", "value": "shh" }},
{param}
] }},
"item": [
{{ "name": "Ping", "request": {{ "method": "GET", "url": "https://x/ping" }} }}
]
}}"#
)
}
#[test]
fn array_valued_auth_param_does_not_empty_the_collection() {
for value in [r#"[]"#, r#"[{"key": "a", "value": "b"}]"#, r#"{"a": 1}"#] {
let json = collection_with(&format!(
r#"{{ "key": "tokenRequestParams", "value": {value}, "type": "any" }}"#
));
let out = convert_postman(&json);
assert!(
out.entries.iter().any(|e| e.title == "Ping"),
"value {value} emptied the collection"
);
assert!(
out.notes
.iter()
.all(|n| !n.detail.contains("could not be read")),
"value {value} failed to parse"
);
}
}
#[test]
fn scalar_valued_fields_stringify() {
let json = r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [ { "name": "Ping", "request": {
"method": "GET", "url": "https://x/ping",
"header": [ { "key": "X-Retry", "value": 3 },
{ "key": "X-Debug", "value": true } ] } } ]
}"#;
let entries = import_postman(json);
let headers = &entries[0].headers;
assert_eq!(
headers.iter().find(|h| h.key == "X-Retry").unwrap().value,
"3"
);
assert_eq!(
headers.iter().find(|h| h.key == "X-Debug").unwrap().value,
"true"
);
}
#[test]
fn null_method_falls_back_to_get() {
let json = r#"{
"info": { "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [ { "name": "Ping", "request": { "method": null, "url": "https://x/ping" } } ]
}"#;
assert_eq!(import_postman(json)[0].method, "GET");
}
#[test]
fn unreadable_collection_is_reported_rather_than_silently_empty() {
let json = r#"{ "info": { "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": "not a list" }"#;
let out = convert_postman(json);
assert!(out.entries.is_empty());
assert_eq!(out.notes.len(), 1);
assert!(out.notes[0].item.is_empty());
assert!(out.notes[0].detail.contains("could not be read"));
}
fn with_auth(auth: &str) -> ConvertedCollection {
convert_postman(&format!(
r#"{{
"info": {{ "name": "d", "schema": "https://schema.getpostman.com/..v2.1.0" }},
"item": [ {{ "name": "r", "request": {{ "method": "GET",
"url": "https://api.example.com/v1/x", "auth": {auth} }} }} ]
}}"#
))
}
#[test]
fn two_users_on_one_client_each_get_their_own_token() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"Alice","auth":{"type":"oauth2","oauth2":[
{"key":"accessTokenUrl","value":"https://id/t"},
{"key":"grant_type","value":"password"},
{"key":"clientId","value":"cli"},
{"key":"username","value":"alice"},
{"key":"password","value":"alice-pw"}]},
"item":[{"name":"me","request":{"method":"GET","url":"https://h/me"}}]},
{"name":"Bob","auth":{"type":"oauth2","oauth2":[
{"key":"accessTokenUrl","value":"https://id/t"},
{"key":"grant_type","value":"password"},
{"key":"clientId","value":"cli"},
{"key":"username","value":"bob"},
{"key":"password","value":"bob-pw"}]},
"item":[{"name":"me","request":{"method":"GET","url":"https://h/me"}}]}]}"#;
let c = convert_postman(json);
let tokens: Vec<_> = c
.entries
.iter()
.filter(|e| e.title.ends_with("Get access token"))
.collect();
assert_eq!(tokens.len(), 2, "one token request per user");
let users: Vec<&str> = tokens
.iter()
.filter_map(|t| t.form_fields.iter().find(|f| f.key == "username"))
.map(|f| f.value.as_str())
.collect();
assert_eq!(users, vec!["alice", "bob"]);
let bob = c.entries.iter().find(|e| e.title == "Bob/me").unwrap();
let alice = c.entries.iter().find(|e| e.title == "Alice/me").unwrap();
let token_of = |e: &HurlEntry| {
e.headers
.iter()
.find(|h| h.key == "Authorization")
.map(|h| h.value.clone())
.unwrap_or_default()
};
assert_ne!(
token_of(bob),
token_of(alice),
"each user's requests use their own captured token"
);
}
#[test]
fn a_generated_token_never_takes_a_variable_name_already_in_use() {
let json = r#"{"info":{"name":"d","schema":"x"},
"variable":[{"key":"access_token","value":"a-preset-value"}],
"item":[{"name":"F","auth":{"type":"oauth2","oauth2":[
{"key":"accessTokenUrl","value":"https://id/t"},
{"key":"grant_type","value":"client_credentials"},
{"key":"clientId","value":"cli"}]},
"item":[{"name":"x","request":{"method":"GET","url":"https://h/x"}}]}]}"#;
let c = convert_postman(json);
let tok = c
.entries
.iter()
.find(|e| e.title.ends_with("Get access token"))
.unwrap();
let captured = &tok.captures[0].0;
assert_ne!(
captured, "access_token",
"the user's variable is left alone"
);
assert!(
c.variables
.iter()
.any(|(k, v)| k == "access_token" && v == "a-preset-value"),
"and still holds its value"
);
let req = c.entries.iter().find(|e| e.title == "F/x").unwrap();
assert!(
req.headers
.iter()
.any(|h| h.key == "Authorization" && h.value.contains(captured)),
"the request uses the generated name"
);
}
#[test]
fn a_commented_out_capture_is_not_a_capture() {
let script = r#"[
"// pm.environment.set(\"token\", jsonData.secret)",
"/* pm.environment.set(\"blocked\", jsonData.b) */",
"console.log(\"pm.environment.set('quoted', jsonData.c)\")",
"pm.environment.set(\"real\", jsonData.ok)"
]"#;
let json = format!(
r#"{{"info":{{"name":"d","schema":"x"}},"item":[
{{"name":"t","request":{{"method":"GET","url":"https://h/x"}},
"event":[{{"listen":"test","script":{{"exec":{script}}}}}]}}]}}"#
);
let e = import_postman(&json);
let names: Vec<&str> = e[0].captures.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["real"], "only the live call is captured");
}
#[test]
fn graphql_variables_given_as_an_object_are_kept() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"gql","request":{"method":"POST","url":"https://h/graphql",
"body":{"mode":"graphql","graphql":{
"query":"query($id:ID!){user(id:$id){name}}",
"variables":{"id":"42"}}}}}]}"#;
let e = import_postman(json);
let body = e[0].body_src.clone().unwrap();
let sent: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(sent["variables"]["id"], "42");
}
#[test]
fn a_url_kept_only_as_pieces_is_rebuilt() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"s","request":{"method":"GET","url":{
"protocol":"https","host":["api","example","com"],"port":"8443",
"path":["v1","users"],"query":[{"key":"page","value":"2"}]}}}]}"#;
let e = import_postman(json);
assert_eq!(e[0].url, "https://api.example.com:8443/v1/users?page=2");
}
#[test]
fn an_enabled_query_missing_from_the_url_text_is_added_once() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"d","request":{"method":"GET","url":{
"raw":"https://h/y?page=1",
"query":[{"key":"page","value":"1"},{"key":"token","value":"abc"}]}}}]}"#;
let e = import_postman(json);
assert_eq!(
e[0].url, "https://h/y?page=1&token=abc",
"the missing one is added, the shared one is not duplicated"
);
}
#[test]
fn a_query_parameter_repeated_in_the_list_keeps_every_value() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"d","request":{"method":"GET","url":{
"raw":"https://h/y?tag=a",
"query":[{"key":"tag","value":"a"},{"key":"tag","value":"b"}]}}}]}"#;
let e = import_postman(json);
assert_eq!(
e[0].url, "https://h/y?tag=a&tag=b",
"the second value of the pair is still sent"
);
}
#[test]
fn a_url_fragment_is_dropped_and_reported() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"h","request":{"method":"GET",
"url":"https://h/search?q=hurl#results"}}]}"#;
let c = convert_postman(json);
assert_eq!(c.entries[0].url, "https://h/search?q=hurl");
assert!(
c.notes.iter().any(|n| n.detail.contains("#results")),
"the loss is reported, not silent: {:?}",
c.notes
);
let back = crate::hurl::parse_hurl(&c.entries[0].to_hurl());
assert_eq!(back.len(), 1, "and the file reads back as one request");
assert_eq!(back[0].url, "https://h/search?q=hurl");
}
#[test]
fn an_aws_service_without_a_region_is_left_to_curl() {
let c = with_auth(
r#"{ "type": "awsv4", "awsv4": [
{ "key": "accessKey", "value": "AKIA1" },
{ "key": "service", "value": "s3" } ] }"#,
);
let sigv4 = c.entries[0]
.options
.iter()
.find(|o| o.key == "aws-sigv4")
.map(|o| o.value.clone());
assert_eq!(sigv4.as_deref(), Some("aws:amz"));
}
#[test]
fn an_api_key_sent_as_a_header_is_not_announced_as_a_query_parameter() {
let c = with_auth(
r#"{ "type": "apikey", "apikey": [
{ "key": "key", "value": "X-Api-Key" },
{ "key": "value", "value": "secret" },
{ "key": "in", "value": "cookie" } ] }"#,
);
assert!(
!c.notes.iter().any(|n| n.detail.contains("query")),
"no query-string claim for a key that went to a header: {:?}",
c.notes
);
}
}
#[cfg(test)]
mod script_tests {
use super::*;
#[test]
fn an_empty_script_tab_is_not_reported_as_a_lost_script() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"prerequest","script":{"exec":[""]}},
{"listen":"test","script":{"exec":["",""]}}],
"request":{"method":"GET","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert!(
!c.notes.iter().any(|n| n.detail.contains("script")),
"nothing was lost, so nothing is claimed: {:?}",
c.notes
);
}
#[test]
fn a_folder_pre_request_script_reaches_every_request_inside_it() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"F","event":[{"listen":"prerequest","script":{"exec":[
"pm.environment.set('transaction_id', require('uuid').v4());",
"pm.environment.set('retries', 0);"]}}],
"item":[{"name":"a","request":{"method":"POST","url":"https://h/a"}},
{"name":"b","request":{"method":"POST","url":"https://h/b"}}]}]}"#;
let c = convert_postman(json);
for e in &c.entries {
assert_eq!(
e.generators,
vec![
("transaction_id".to_string(), "uuid".to_string()),
("retries".to_string(), "0".to_string())
],
"{} computes the folder's values",
e.title
);
}
let script_notes: Vec<&ConversionNote> = c
.notes
.iter()
.filter(|n| n.detail.contains("pre-request script"))
.collect();
assert_eq!(
script_notes.len(),
1,
"one folder script is one note, not one per request: {:?}",
c.notes
);
assert_eq!(
script_notes[0].item, "F",
"filed against the folder that holds it"
);
}
#[test]
fn the_usual_pre_request_computations_become_generators() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"prerequest","script":{"exec":[
"pm.environment.set('id', uuidv4());",
"pm.collectionVariables.set('ms', Date.now());",
"pm.variables.set('secs', Math.floor(Date.now() / 1000));",
"pm.environment.set('when', new Date().toISOString());",
"pm.environment.set('guid', pm.variables.replaceIn('{{$guid}}'));",
"pm.environment.set('tries', 0);",
"pm.environment.set('who', 'alice');"]}}],
"request":{"method":"GET","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert_eq!(
c.entries[0].generators,
vec![
("id".to_string(), "uuid".to_string()),
("ms".to_string(), "timestamp_ms".to_string()),
("secs".to_string(), "timestamp".to_string()),
("when".to_string(), "iso8601".to_string()),
("guid".to_string(), "uuid".to_string()),
("tries".to_string(), "0".to_string()),
("who".to_string(), "\"alice\"".to_string()),
]
);
let back = crate::hurl::parse_hurl(&c.entries[0].to_hurl());
assert_eq!(
back[0].generators, c.entries[0].generators,
"and the block reads back"
);
}
#[test]
fn a_status_assertion_becomes_the_expected_status() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"test","script":{"exec":[
"pm.test('bad request', function () {",
" pm.response.to.have.status(400);",
"});"]}}],
"request":{"method":"POST","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert_eq!(c.entries[0].expected_status, Some(400));
}
#[test]
fn body_checks_inside_a_test_callback_become_asserts() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"test","script":{"exec":[
"pm.test('shape', () => {",
" const b = pm.response.json();",
" pm.expect(b.status).to.eql('Matched');",
" pm.expect(b.ModelState['body.Image']).to.not.be.empty;",
" pm.expect(b.errors.length).to.equal(1);",
"});"]}}],
"request":{"method":"POST","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert_eq!(
c.entries[0].asserts,
vec![
"jsonpath \"$.status\" == \"Matched\"".to_string(),
"jsonpath \"$.ModelState['body.Image']\" not isEmpty".to_string(),
"jsonpath \"$.errors\" count == 1".to_string(),
]
);
let back = crate::hurl::parse_hurl(&c.entries[0].to_hurl());
assert_eq!(back[0].asserts, c.entries[0].asserts, "and they read back");
}
#[test]
fn assertions_the_script_only_sometimes_runs_are_not_hoisted() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"test","script":{"exec":[
"const matched = (b) => { pm.expect(b.result).to.eql('Matched'); };",
"const notMatched = (b) => { pm.expect(b.result).to.eql('NotMatched'); };",
"const b = pm.response.json();",
"if (pm.environment.get('expect') === 'yes') { matched(b); } else { notMatched(b); }",
"if (b.code) pm.expect(b.code).to.eql(2);"]}}],
"request":{"method":"POST","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert!(
c.entries[0].asserts.is_empty(),
"nothing here always holds: {:?}",
c.entries[0].asserts
);
assert!(
c.notes
.iter()
.any(|n| n.detail.contains("test script was dropped")),
"and the user is told, so they can assert it by hand: {:?}",
c.notes
);
}
#[test]
fn a_script_choosing_the_next_request_is_reported_as_lost_order() {
let notes = next_request_notes(
"x",
"if (pm.response.code === 202) pm.execution.setNextRequest('poll');",
);
assert!(
notes
.iter()
.any(|d| d.contains("sometimes jumped to `poll`") && d.contains("file order")),
"{notes:?}"
);
}
#[test]
fn a_request_that_reran_itself_is_named_as_a_polling_loop() {
let notes = next_request_notes(
"get_result",
"if (!done) { pm.execution.setNextRequest('get_result'); }",
);
assert!(
notes
.iter()
.any(|d| d.contains("polling loop") && d.contains("retry")),
"{notes:?}"
);
}
#[test]
fn an_unconditional_jump_is_reported_as_an_order_to_write_down() {
let notes = next_request_notes("submit", "pm.execution.setNextRequest('get_result');");
assert!(
notes
.iter()
.any(|d| d.contains("always ran `get_result` next") && d.contains("PaperTrail")),
"{notes:?}"
);
}
#[test]
fn stopping_the_run_is_reported_as_the_requests_after_it_now_running() {
let notes = next_request_notes("x", "pm.execution.setNextRequest(null);");
assert!(
notes.iter().any(|d| d.contains("stopped the run here")),
"{notes:?}"
);
}
#[test]
fn a_computed_next_request_name_is_reported_as_not_being_in_the_file() {
let notes = next_request_notes("x", "pm.execution.setNextRequest('Test Case ' + (n + 1));");
assert!(
notes.iter().any(|d| d.contains("worked out as it ran")),
"{notes:?}"
);
}
#[test]
fn the_same_jump_twice_is_one_note() {
let notes = next_request_notes(
"x",
"pm.execution.setNextRequest('a');\npm.execution.setNextRequest('a');",
);
assert_eq!(notes.len(), 1, "{notes:?}");
}
#[test]
fn random_alpha_numeric_is_computed_rather_than_left_to_be_supplied() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","request":{"method":"GET","url":"https://h/x?c={{$randomAlphaNumeric}}"}}]}"#;
let c = convert_postman(json);
assert!(
c.entries[0].generators.contains(&(
"randomAlphaNumeric".to_string(),
"random_alnum(1)".to_string()
)),
"{:?}",
c.entries[0].generators
);
}
#[test]
fn replace_in_claims_the_same_names_as_a_plain_placeholder() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"x","event":[{"listen":"prerequest","script":{"exec":[
"pm.environment.set('c', pm.variables.replaceIn('{{$randomAlphaNumeric}}'));"]}}],
"request":{"method":"GET","url":"https://h/x"}}]}"#;
let c = convert_postman(json);
assert!(
c.entries[0]
.generators
.contains(&("c".to_string(), "random_alnum(1)".to_string())),
"{:?}",
c.entries[0].generators
);
}
#[test]
fn a_deep_equality_against_an_object_becomes_one_assert_per_leaf() {
let asserts = asserts_of(
"pm.test('t', () => { pm.expect(pm.response.json().user).to.eql({ id: 7, name: 'Ada' }); });",
);
assert!(
asserts.contains(&"jsonpath \"$.user.id\" == 7".to_string()),
"{asserts:?}"
);
assert!(
asserts.contains(&"jsonpath \"$.user.name\" == \"Ada\"".to_string()),
"{asserts:?}"
);
}
#[test]
fn a_deep_equality_against_an_array_pins_its_length_too() {
let asserts = asserts_of(
"pm.test('t', () => { pm.expect(pm.response.json().ids).to.eql([1, 2]); });",
);
assert!(
asserts.contains(&"jsonpath \"$.ids\" count == 2".to_string()),
"{asserts:?}"
);
assert!(
asserts.contains(&"jsonpath \"$.ids[0]\" == 1".to_string()),
"{asserts:?}"
);
}
#[test]
fn a_deep_equality_holding_an_expression_is_not_half_imported() {
let asserts = asserts_of(
"pm.test('t', () => { pm.expect(pm.response.json().user).to.eql({ id: expectedId }); });",
);
assert!(asserts.is_empty(), "{asserts:?}");
}
#[test]
fn a_shallow_equal_against_an_object_is_not_read_as_a_deep_one() {
let asserts = asserts_of(
"pm.test('t', () => { pm.expect(pm.response.json().user).to.equal({ id: 7 }); });",
);
assert!(asserts.is_empty(), "{asserts:?}");
}
#[test]
fn a_space_inside_an_expected_string_survives() {
let asserts = asserts_of(
"pm.test('t', () => { pm.expect(pm.response.json().msg).to.equal('Not Found'); });",
);
assert_eq!(
asserts,
vec!["jsonpath \"$.msg\" == \"Not Found\"".to_string()]
);
}
fn asserts_of(script: &str) -> Vec<String> {
let json = format!(
r#"{{"info":{{"name":"d","schema":"x"}},"item":[
{{"name":"x","event":[{{"listen":"test","script":{{"exec":[{}]}}}}],
"request":{{"method":"GET","url":"https://h/x"}}}}]}}"#,
serde_json::to_string(script).unwrap()
);
convert_postman(&json).entries[0].asserts.clone()
}
#[test]
fn an_inherited_jump_is_reported_once_against_the_folder() {
let json = r#"{"info":{"name":"d","schema":"x"},"item":[
{"name":"F","event":[{"listen":"test","script":{"exec":[
"pm.execution.setNextRequest('poll');"]}}],
"item":[
{"name":"a","event":[{"listen":"test","script":{"exec":[
"pm.test('t', () => { pm.response.to.have.status(200); });"]}}],
"request":{"method":"GET","url":"https://h/a"}},
{"name":"b","event":[{"listen":"test","script":{"exec":[
"pm.test('t', () => { pm.response.to.have.status(200); });"]}}],
"request":{"method":"GET","url":"https://h/b"}}]}]}"#;
let c = convert_postman(json);
let jumps: Vec<&ConversionNote> = c
.notes
.iter()
.filter(|n| n.detail.contains("`poll`"))
.collect();
assert_eq!(jumps.len(), 1, "{:?}", c.notes);
assert_eq!(jumps[0].item, "F", "filed against the folder that wrote it");
}
fn next_request_notes(title: &str, script: &str) -> Vec<String> {
super::next_request_fates(script, title)
}
}
#[cfg(test)]
mod defect_regressions {
use super::*;
#[test]
fn an_environment_value_referring_to_another_is_worked_out_on_import() {
let env = r#"{"values":[
{"key":"scheme","value":"https"},
{"key":"host","value":"api.example.com"},
{"key":"base_url","value":"{{scheme}}://{{host}}/v1"},
{"key":"same","value":"{{host}}"},
{"key":"secret","value":"{{ op://Vault/api/token }}"}
]}"#;
let got = postman_env_values(env).unwrap();
let value = |k: &str| {
got.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.clone())
.unwrap()
};
assert_eq!(value("base_url"), "https://api.example.com/v1");
assert_eq!(
value("same"),
"api.example.com",
"a value that is nothing but a reference is the referenced value"
);
assert_eq!(
value("secret"),
"{{ op://Vault/api/token }}",
"a provider reference is not a variable reference and is left alone"
);
assert!(postman_env_unresolved_refs(env).is_empty());
}
#[test]
fn a_chain_resolves_and_what_cannot_be_resolved_is_reported() {
let env = r#"{"values":[
{"key":"a","value":"{{b}}"},
{"key":"b","value":"{{c}}"},
{"key":"c","value":"end"},
{"key":"loop1","value":"{{loop2}}"},
{"key":"loop2","value":"{{loop1}}"},
{"key":"elsewhere","value":"{{from_the_collection}}/path"}
]}"#;
let got = postman_env_values(env).unwrap();
let value = |k: &str| {
got.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.clone())
.unwrap()
};
assert_eq!(value("a"), "end", "a chain is followed to the end");
assert!(
value("elsewhere").contains("{{from_the_collection}}"),
"an unreachable name is left as written: {}",
value("elsewhere")
);
let unresolved: Vec<String> = postman_env_unresolved_refs(env)
.into_iter()
.map(|(k, _)| k)
.collect();
assert!(
unresolved.contains(&"elsewhere".to_string()),
"{unresolved:?}"
);
assert!(
unresolved.contains(&"loop1".to_string()) && unresolved.contains(&"loop2".to_string()),
"a loop settles rather than spinning, and says so: {unresolved:?}"
);
}
fn j(s: &str) -> String {
s.lines()
.map(|l| serde_json::to_string(l).unwrap())
.collect::<Vec<_>>()
.join(",")
}
fn conv(pre: &str, test: &str, url: &str, title: &str) -> ConvertedCollection {
let mut ev = Vec::new();
if !pre.is_empty() {
ev.push(format!(
r#"{{"listen":"prerequest","script":{{"exec":[{}]}}}}"#,
j(pre)
));
}
if !test.is_empty() {
ev.push(format!(
r#"{{"listen":"test","script":{{"exec":[{}]}}}}"#,
j(test)
));
}
convert_postman(&format!(
r#"{{"info":{{"name":"d","schema":"x"}},"item":[
{{"name":"{title}","event":[{}],"request":{{"method":"GET","url":"{url}"}}}}]}}"#,
ev.join(",")
))
}
#[test]
fn a_guard_inside_a_function_test_callback_is_not_hoisted() {
let c = conv(
"",
"pm.test('t', function () {\n if (jsonData.ok) {\n pm.expect(jsonData.name).to.equal('Ada');\n }\n});",
"https://h/x",
"x",
);
assert!(
c.entries[0].asserts.is_empty(),
"guarded assert hoisted: {:?}",
c.entries[0].asserts
);
let st = conv(
"",
"pm.test('t', function () {\n if (ok) {\n pm.response.to.have.status(500);\n }\n});",
"https://h/x",
"x",
);
assert_eq!(st.entries[0].expected_status, None);
let nx = conv(
"",
"pm.test('t', function () {\n if (bad) {\n postman.setNextRequest('Retry');\n }\n});",
"https://h/x",
"Login",
);
assert!(
nx.notes
.iter()
.any(|n| n.detail.contains("sometimes jumped to `Retry`")),
"{:?}",
nx.notes
);
let plain = conv(
"",
"pm.test('t', function () {\n pm.expect(jsonData.name).to.equal('Ada');\n});",
"https://h/x",
"x",
);
assert_eq!(
plain.entries[0].asserts,
vec!["jsonpath \"$.name\" == \"Ada\"".to_string()]
);
let arrow = conv(
"",
"pm.test('t', () => {\n if (jsonData.ok) {\n pm.expect(jsonData.name).to.equal('Ada');\n }\n});",
"https://h/x",
"x",
);
assert!(arrow.entries[0].asserts.is_empty());
}
#[test]
fn a_brace_less_guard_split_across_lines_is_still_conditional() {
let c = conv(
"",
"if (jsonData.ok)\n pm.expect(jsonData.name).to.equal('Ada');",
"https://h/x",
"x",
);
assert!(
c.entries[0].asserts.is_empty(),
"{:?}",
c.entries[0].asserts
);
let e = conv(
"",
"if (jsonData.ok) {\n} else\n pm.expect(jsonData.name).to.equal('Ada');",
"https://h/x",
"x",
);
assert!(
e.entries[0].asserts.is_empty(),
"{:?}",
e.entries[0].asserts
);
let f = conv(
"",
"for (const x of jsonData.items)\n pm.expect(jsonData.name).to.equal('Ada');",
"https://h/x",
"x",
);
assert!(
f.entries[0].asserts.is_empty(),
"{:?}",
f.entries[0].asserts
);
let ok = conv(
"",
"const a = 1;\npm.expect(jsonData.name).to.equal('Ada');",
"https://h/x",
"x",
);
assert_eq!(
ok.entries[0].asserts,
vec!["jsonpath \"$.name\" == \"Ada\"".to_string()]
);
let one = conv(
"",
"if (jsonData.ok) pm.expect(jsonData.name).to.equal('Ada');",
"https://h/x",
"x",
);
assert!(one.entries[0].asserts.is_empty());
}
#[test]
fn a_string_concatenation_is_not_read_as_one_string() {
assert_eq!(unquote("'Not' + ' ' + 'Found'"), None);
assert_eq!(unquote("'Found'"), Some("Found"));
assert_eq!(unquote("'it\\'s'"), Some("it\\'s"));
let a = conv(
"",
"pm.expect(jsonData.name).to.equal('Not' + ' ' + 'Found');",
"https://h/x",
"x",
);
assert!(
a.entries[0].asserts.is_empty(),
"{:?}",
a.entries[0].asserts
);
assert!(a.notes.iter().any(|n| n.detail.contains("dropped")));
let g = conv(
"pm.environment.set('key', 'abc' + '-' + 'def');",
"",
"https://h/x",
"x",
);
assert!(
g.entries[0].generators.is_empty(),
"{:?}",
g.entries[0].generators
);
let ok = conv(
"pm.environment.set('key', 'abcdef');",
"",
"https://h/x",
"x",
);
assert_eq!(
ok.entries[0].generators,
vec![("key".to_string(), "\"abcdef\"".to_string())]
);
}
#[test]
fn a_space_in_a_subject_key_or_header_name_survives() {
let k = conv(
"",
"pm.expect(jsonData['full name']).to.equal('Ada');",
"https://h/x",
"x",
);
assert_eq!(
k.entries[0].asserts,
vec!["jsonpath \"$['full name']\" == \"Ada\"".to_string()]
);
let h = conv(
"",
"pm.expect(pm.response.headers.get('X Weird')).to.equal('a');",
"https://h/x",
"x",
);
assert_eq!(
h.entries[0].asserts,
vec!["header \"X Weird\" == \"a\"".to_string()]
);
let p = conv(
"",
"pm.expect(jsonData.msg).to.equal('Not Found');",
"https://h/x",
"x",
);
assert_eq!(
p.entries[0].asserts,
vec!["jsonpath \"$.msg\" == \"Not Found\"".to_string()]
);
}
#[test]
fn a_capture_over_response_json_is_captured_not_lost() {
let c = conv(
"",
"pm.test('ok', function () { pm.response.to.have.status(200); });\npm.environment.set('token', pm.response.json().token);",
"https://h/x",
"x",
);
assert_eq!(
c.entries[0].captures,
vec![("token".to_string(), "jsonpath \"$.token\"".to_string())]
);
assert_eq!(c.entries[0].expected_status, Some(200));
assert!(
c.notes.iter().any(|n| n.detail.contains("[Captures]")),
"{:?}",
c.notes
);
}
#[test]
fn the_older_postman_api_still_becomes_captures() {
let c = conv(
"",
"var jsonData = JSON.parse(responseBody);\npostman.setEnvironmentVariable('token', jsonData.token);",
"https://h/x",
"x",
);
assert_eq!(
c.entries[0].captures,
vec![("token".to_string(), "jsonpath \"$.token\"".to_string())]
);
}
#[test]
fn a_legacy_body_variable_under_any_name_is_a_root() {
let c = conv(
"",
"var body = JSON.parse(responseBody);\npostman.setEnvironmentVariable('id', body.user.id);",
"https://h/x",
"x",
);
assert_eq!(
c.entries[0].captures,
vec![("id".to_string(), "jsonpath \"$.user.id\"".to_string())]
);
}
#[test]
fn a_name_standing_for_part_of_the_body_is_a_root_too() {
let c = conv(
"",
"const body = pm.response.json();\nconst data = body.data;\nconst first = data.items[0];\npm.environment.set('id', first.id);\npm.environment.set('total', data.total);",
"https://h/x",
"x",
);
assert_eq!(
c.entries[0].captures,
vec![
(
"id".to_string(),
"jsonpath \"$.data.items[0].id\"".to_string()
),
("total".to_string(), "jsonpath \"$.data.total\"".to_string()),
]
);
}
#[test]
fn a_name_declared_twice_is_not_treated_as_a_root() {
let c = conv(
"",
"const body = pm.response.json();\nconst d = body.a;\nconst d = body.b;\npm.environment.set('id', d.id);",
"https://h/x",
"x",
);
assert!(
c.entries[0].captures.is_empty(),
"an ambiguous name was resolved anyway: {:?}",
c.entries[0].captures
);
}
#[test]
fn a_conditional_generator_or_capture_is_left_as_residue() {
let g = conv(
"if (!pm.environment.get('id')) { pm.environment.set('id', require('uuid').v4()); }",
"",
"https://h/x",
"x",
);
assert!(
g.entries[0].generators.is_empty(),
"guarded set-once became an unconditional row: {:?}",
g.entries[0].generators
);
assert!(g.notes.iter().any(|n| n.detail.contains("dropped")));
let e = conv(
"if (a) {\n pm.environment.set('id', 1);\n} else {\n pm.environment.set('id', 2);\n}",
"",
"https://h/x",
"x",
);
assert!(
e.entries[0].generators.is_empty(),
"an if/else silently picked a branch: {:?}",
e.entries[0].generators
);
let c = conv(
"",
"if (pm.response.code === 200) {\n pm.environment.set('token', jsonData.token);\n}",
"https://h/x",
"x",
);
assert!(
c.entries[0].captures.is_empty(),
"a guarded capture would error whenever the guard was not taken: {:?}",
c.entries[0].captures
);
assert!(c.notes.iter().any(|n| n.detail.contains("dropped")));
let okg = conv(
"pm.environment.set('id', require('uuid').v4());",
"",
"https://h/x",
"x",
);
assert_eq!(
okg.entries[0].generators,
vec![("id".to_string(), "uuid".to_string())]
);
let okc = conv(
"",
"pm.environment.set('token', jsonData.token);",
"https://h/x",
"x",
);
assert_eq!(
okc.entries[0].captures,
vec![("token".to_string(), "jsonpath \"$.token\"".to_string())]
);
}
#[test]
fn an_invalid_capture_name_is_refused() {
let c = conv(
"",
"pm.environment.set('my token', jsonData.token);",
"https://h/x",
"x",
);
assert!(
c.entries[0].captures.is_empty(),
"{:?}",
c.entries[0].captures
);
assert!(c.notes.iter().any(|n| n.detail.contains("dropped")));
let hurl = crate::hurl::collection_to_hurl(&c.entries);
assert!(
!hurl.contains("my token: jsonpath"),
"an invalid name was written into the file: {hurl}"
);
assert_eq!(crate::hurl::parse_hurl(&hurl).len(), 1);
let ok = conv(
"",
"pm.environment.set('token', jsonData.token);",
"https://h/x",
"x",
);
assert_eq!(
ok.entries[0].captures,
vec![("token".to_string(), "jsonpath \"$.token\"".to_string())]
);
}
#[test]
fn a_dynamic_variable_does_not_reuse_a_differing_gen_row() {
let c = conv(
"pm.environment.set('timestamp', Date.now());",
"",
"https://h/x?t={{$timestamp}}",
"x",
);
assert_eq!(
c.entries[0].generators,
vec![
("timestamp".to_string(), "timestamp_ms".to_string()),
("timestamp_1".to_string(), "timestamp".to_string()),
]
);
assert_eq!(c.entries[0].url, "https://h/x?t={{timestamp_1}}");
assert!(
c.notes.iter().any(|n| n
.detail
.contains("computed by this request's `[Gen]` block as `timestamp`")),
"{:?}",
c.notes
);
let ok = conv("", "", "https://h/x?t={{$timestamp}}", "x");
assert_eq!(
ok.entries[0].generators,
vec![("timestamp".to_string(), "timestamp".to_string())]
);
assert_eq!(ok.entries[0].url, "https://h/x?t={{timestamp}}");
}
#[test]
fn a_brace_in_a_regex_literal_does_not_shift_the_block_stack() {
let c = conv(
"",
"if (bad) {\n const m = pm.response.text().match(/\\{([^}]*)\\}/);\n pm.expect(jsonData.a).to.equal(1);\n}",
"https://h/x",
"x",
);
assert!(
c.entries[0].asserts.is_empty(),
"the extra `}}` popped the if and hoisted a guarded assert: {:?}",
c.entries[0].asserts
);
let json = format!(
r#"{{"info":{{"name":"d","schema":"x"}},"item":[
{{"name":"F","event":[{{"listen":"test","script":{{"exec":[{}]}}}}],
"item":[{{"name":"r","event":[{{"listen":"test","script":{{"exec":[{}]}}}}],
"request":{{"method":"GET","url":"https://h/x"}}}}]}}]}}"#,
j("const re = /\\{/;"),
j("pm.expect(jsonData.a).to.equal(1);")
);
let f = convert_postman(&json);
assert_eq!(
f.entries[0].asserts,
vec!["jsonpath \"$.a\" == 1".to_string()]
);
let d = conv(
"",
"const half = total / 2;\nif (bad) {\n pm.expect(jsonData.a).to.equal(1);\n}",
"https://h/x",
"x",
);
assert!(
d.entries[0].asserts.is_empty(),
"{:?}",
d.entries[0].asserts
);
}
#[test]
fn a_number_hurl_cannot_read_is_declined() {
for src in ["1e3", ".5", "Infinity", "NaN"] {
let c = conv(
"",
&format!("pm.expect(jsonData.a).to.equal({src});"),
"https://h/x",
"x",
);
assert!(
c.entries[0].asserts.is_empty(),
"{src} was asserted: {:?}",
c.entries[0].asserts
);
}
for (src, line) in [
("200", "jsonpath \"$.a\" == 200"),
("1.5", "jsonpath \"$.a\" == 1.5"),
("-3", "jsonpath \"$.a\" == -3"),
] {
let c = conv(
"",
&format!("pm.expect(jsonData.a).to.equal({src});"),
"https://h/x",
"x",
);
assert_eq!(c.entries[0].asserts, vec![line.to_string()], "{src}");
let back = crate::hurl::parse_hurl(&crate::hurl::collection_to_hurl(&c.entries));
assert_eq!(back[0].asserts, c.entries[0].asserts, "{src} round-trips");
}
let g = conv("pm.environment.set('n', 1e3);", "", "https://h/x", "x");
assert!(
g.entries[0].generators.is_empty(),
"{:?}",
g.entries[0].generators
);
let ok = conv("pm.environment.set('n', 5);", "", "https://h/x", "x");
assert_eq!(
ok.entries[0].generators,
vec![("n".to_string(), "5".to_string())]
);
assert!(crate::generators::check(&ok.entries[0].generators).is_empty());
}
#[test]
fn a_second_differing_status_is_noted_not_dropped_silently() {
let c = conv(
"",
"pm.test('a', () => { pm.response.to.have.status(200); });\npm.test('b', () => { pm.expect(pm.response.code).to.equal(201); });",
"https://h/x",
"x",
);
assert_eq!(c.entries[0].expected_status, Some(200));
assert!(
c.notes
.iter()
.any(|n| n.detail.contains("the rest of it was dropped")),
"the losing status left no note: {:?}",
c.notes
);
let ok = conv(
"",
"pm.test('a', () => { pm.response.to.have.status(200); });\npm.test('b', () => { pm.expect(pm.response.code).to.equal(200); });",
"https://h/x",
"x",
);
assert_eq!(ok.entries[0].expected_status, Some(200));
assert!(
!ok.notes
.iter()
.any(|n| n.detail.contains("the rest of it was dropped")),
"{:?}",
ok.notes
);
}
#[test]
fn a_folder_scripts_residue_is_filed_against_the_folder() {
let json = format!(
r#"{{"info":{{"name":"d","schema":"x"}},"item":[
{{"name":"F","event":[{{"listen":"test","script":{{"exec":[{}]}}}}],
"item":[{{"name":"r","event":[{{"listen":"test","script":{{"exec":[{}]}}}}],
"request":{{"method":"GET","url":"https://h/x"}}}}]}}]}}"#,
j("const t = pm.environment.get('x');\npm.cookies.clear();"),
j("pm.test('t', () => { pm.expect(jsonData.a).to.equal(1); });")
);
let c = convert_postman(&json);
assert_eq!(c.notes.len(), 2, "{:?}", c.notes);
let req = c
.notes
.iter()
.find(|n| n.item == "F/r")
.expect("request note missing");
assert!(
req.detail
.contains("this request's test script became 1 [Asserts]"),
"{:?}",
req
);
assert!(
!req.detail.contains("the rest of it was dropped"),
"the folder's loss was blamed on the request: {:?}",
req
);
let folder = c
.notes
.iter()
.find(|n| n.item == "F")
.expect("folder note missing");
assert!(
folder
.detail
.contains("this folder's test script was dropped"),
"{:?}",
folder
);
}
#[test]
fn a_template_looking_literal_is_not_asserted() {
let c = conv(
"",
"pm.expect(jsonData.a).to.equal('id {{x}} here');",
"https://h/x",
"x",
);
assert!(
c.entries[0].asserts.is_empty(),
"{:?}",
c.entries[0].asserts
);
let d = conv(
"",
"pm.expect(jsonData).to.eql({ a: 'x {{y}} z' });",
"https://h/x",
"x",
);
assert!(
d.entries[0].asserts.is_empty(),
"{:?}",
d.entries[0].asserts
);
let ok = conv(
"",
"pm.expect(jsonData.a).to.equal('plain');",
"https://h/x",
"x",
);
assert_eq!(
ok.entries[0].asserts,
vec!["jsonpath \"$.a\" == \"plain\"".to_string()]
);
}
#[test]
fn a_capture_name_defined_twice_keeps_only_the_last() {
let c = conv(
"",
"pm.environment.set('id', jsonData.a);\npm.environment.set('id', jsonData.b);",
"https://h/x",
"x",
);
assert_eq!(
c.entries[0].captures,
vec![("id".to_string(), "jsonpath \"$.b\"".to_string())]
);
assert!(
c.notes.iter().any(|n| n.detail.contains("1 [Captures]")),
"{:?}",
c.notes
);
}
#[test]
fn controls_still_convert_correctly() {
let a = conv(
"",
"pm.test('t', () => {\n pm.expect(pm.response.code).to.equal(400);\n pm.expect(pm.response.json()).to.eql({ Message: 'The request is invalid.', ModelState: { 'body.Image': ['too big',] } });\n});",
"https://h/x",
"x",
);
assert_eq!(a.entries[0].expected_status, Some(400));
assert_eq!(
a.entries[0].asserts,
vec![
"jsonpath \"$.Message\" == \"The request is invalid.\"".to_string(),
"jsonpath \"$.ModelState['body.Image']\" count == 1".to_string(),
"jsonpath \"$.ModelState['body.Image'][0]\" == \"too big\"".to_string(),
]
);
let helper = conv(
"",
"const check = (b) => { pm.expect(b.a).to.equal('one'); };\ncheck(jsonData);",
"https://h/x",
"x",
);
assert!(helper.entries[0].asserts.is_empty());
let equal_obj = conv(
"",
"pm.expect(jsonData).to.equal({ a: 1 });",
"https://h/x",
"x",
);
assert!(equal_obj.entries[0].asserts.is_empty());
let empty_obj = conv("", "pm.expect(jsonData).to.eql({});", "https://h/x", "x");
assert!(empty_obj.entries[0].asserts.is_empty());
assert!(
empty_obj
.notes
.iter()
.any(|n| n.detail.contains("nothing in it reduced"))
);
}
}