use std::collections::HashSet;
use std::time::Duration;
use serde_json::Value;
pub const DEFAULT_BASE_URL: &str = "https://api.postman.com";
#[cfg_attr(not(test), allow(dead_code))]
pub const EU_BASE_URL: &str = "https://api.eu.postman.com";
pub const STRICT_MIN_INTERVAL: Duration = Duration::from_millis(1_000);
pub const GENERAL_MIN_INTERVAL: Duration = Duration::from_millis(200);
pub const COLLECTION_FETCH_COST: Duration = Duration::from_millis(1_000);
pub const ENVIRONMENT_FETCH_COST: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateBucket {
Strict,
General,
}
impl RateBucket {
pub fn min_interval(self) -> Duration {
match self {
RateBucket::Strict => STRICT_MIN_INTERVAL,
RateBucket::General => GENERAL_MIN_INTERVAL,
}
}
}
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
}
impl HttpResponse {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
pub trait Transport: Send + Sync {
fn get(&self, url: &str, api_key: &str) -> Result<HttpResponse, String>;
}
pub struct CurlTransport {
timeout: Duration,
}
impl Default for CurlTransport {
fn default() -> Self {
Self {
timeout: Duration::from_secs(60),
}
}
}
impl Transport for CurlTransport {
fn get(&self, url: &str, api_key: &str) -> Result<HttpResponse, String> {
use curl::easy::{Easy, List};
let mut easy = Easy::new();
easy.url(url).map_err(|e| e.to_string())?;
easy.follow_location(true).map_err(|e| e.to_string())?;
easy.timeout(self.timeout).map_err(|e| e.to_string())?;
easy.connect_timeout(Duration::from_secs(20))
.map_err(|e| e.to_string())?;
easy.useragent(concat!("paperboy/", env!("CARGO_PKG_VERSION")))
.map_err(|e| e.to_string())?;
let mut list = List::new();
list.append(&format!("x-api-key: {api_key}"))
.map_err(|e| e.to_string())?;
list.append("Accept: application/json")
.map_err(|e| e.to_string())?;
easy.http_headers(list).map_err(|e| e.to_string())?;
let mut body = Vec::new();
let mut headers: Vec<(String, String)> = Vec::new();
{
let mut transfer = easy.transfer();
transfer
.write_function(|data| {
body.extend_from_slice(data);
Ok(data.len())
})
.map_err(|e| e.to_string())?;
transfer
.header_function(|raw| {
if let Some((name, value)) = parse_header_line(raw) {
headers.push((name, value));
}
true
})
.map_err(|e| e.to_string())?;
transfer.perform().map_err(|e| e.to_string())?;
}
let status = easy.response_code().map_err(|e| e.to_string())? as u16;
Ok(HttpResponse {
status,
headers,
body: String::from_utf8_lossy(&body).into_owned(),
})
}
}
fn parse_header_line(raw: &[u8]) -> Option<(String, String)> {
let line = String::from_utf8_lossy(raw);
let (name, value) = line.split_once(':')?;
Some((name.trim().to_ascii_lowercase(), value.trim().to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
Unauthorized,
Forbidden(String),
NotFound(String),
RateLimited {
retry_after: Option<u64>,
monthly: bool,
},
Http { status: u16, message: String },
Transport(String),
Parse(String),
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::Unauthorized => write!(f, "invalid or expired Postman API key"),
ApiError::Forbidden(m) => write!(f, "not permitted: {m}"),
ApiError::NotFound(m) => write!(f, "not found: {m}"),
ApiError::RateLimited { monthly: true, .. } => {
write!(f, "Postman API monthly usage limit reached")
}
ApiError::RateLimited {
retry_after: Some(s),
..
} => write!(f, "rate limited by Postman (retry in {s}s)"),
ApiError::RateLimited { .. } => write!(f, "rate limited by Postman"),
ApiError::Http { status, message } if message.is_empty() => {
write!(f, "Postman API returned HTTP {status}")
}
ApiError::Http { status, message } => {
write!(f, "Postman API returned HTTP {status}: {message}")
}
ApiError::Transport(m) => write!(f, "could not reach the Postman API: {m}"),
ApiError::Parse(m) => write!(f, "unexpected response from the Postman API: {m}"),
}
}
}
impl ApiError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
ApiError::RateLimited { monthly: false, .. } | ApiError::Transport(_)
)
}
}
pub fn redact(msg: &str, key: &str) -> String {
if key.is_empty() {
return msg.to_string();
}
msg.replace(key, "***")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceKind {
Personal,
Team,
Private,
Public,
Partner,
Other(String),
}
impl WorkspaceKind {
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"personal" => WorkspaceKind::Personal,
"team" => WorkspaceKind::Team,
"private" => WorkspaceKind::Private,
"public" => WorkspaceKind::Public,
"partner" => WorkspaceKind::Partner,
other => WorkspaceKind::Other(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
WorkspaceKind::Personal => "personal",
WorkspaceKind::Team => "team",
WorkspaceKind::Private => "private",
WorkspaceKind::Public => "public",
WorkspaceKind::Partner => "partner",
WorkspaceKind::Other(s) => s,
}
}
pub fn default_selection() -> Vec<WorkspaceKind> {
vec![
WorkspaceKind::Personal,
WorkspaceKind::Team,
WorkspaceKind::Private,
WorkspaceKind::Partner,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceSummary {
pub id: String,
pub name: String,
pub kind: WorkspaceKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemSummary {
pub uid: String,
pub id: String,
pub name: String,
}
impl ItemSummary {
pub fn fetch_id(&self) -> &str {
if self.uid.is_empty() {
&self.id
} else {
&self.uid
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RateInfo {
pub limit: Option<u64>,
pub remaining: Option<u64>,
pub reset_secs: Option<u64>,
pub limit_month: Option<u64>,
pub remaining_month: Option<u64>,
pub retry_after: Option<u64>,
}
impl RateInfo {
pub fn from_response(res: &HttpResponse) -> Self {
let num = |name: &str| res.header(name).and_then(|v| v.trim().parse::<u64>().ok());
let first = |names: &[&str]| names.iter().find_map(|n| num(n));
let mut info = RateInfo {
limit: first(&["ratelimit-limit", "x-ratelimit-limit"]),
remaining: first(&["ratelimit-remaining", "x-ratelimit-remaining"]),
reset_secs: first(&["ratelimit-reset", "x-ratelimit-reset"]).map(normalize_reset),
limit_month: first(&["ratelimit-limit-month", "x-ratelimit-limit-month"]),
remaining_month: first(&["ratelimit-remaining-month", "x-ratelimit-remaining-month"]),
retry_after: first(&["x-ratelimit-retryafter", "retry-after"]),
};
if let Some(combined) = res.header("ratelimit") {
let parsed = parse_combined_ratelimit(combined);
info.limit = info.limit.or(parsed.0);
info.remaining = info.remaining.or(parsed.1);
info.reset_secs = info.reset_secs.or(parsed.2.map(normalize_reset));
}
info
}
}
fn normalize_reset(v: u64) -> u64 {
const EPOCH_THRESHOLD: u64 = 1_000_000_000;
if v < EPOCH_THRESHOLD {
return v;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
v.saturating_sub(now)
}
fn parse_combined_ratelimit(value: &str) -> (Option<u64>, Option<u64>, Option<u64>) {
let mut limit = None;
let mut remaining = None;
let mut reset = None;
for part in value.split(',') {
let Some((k, v)) = part.split_once('=') else {
continue;
};
let parsed = v.trim().parse::<u64>().ok();
match k.trim().to_ascii_lowercase().as_str() {
"limit" => limit = parsed,
"remaining" => remaining = parsed,
"reset" => reset = parsed,
_ => {}
}
}
(limit, remaining, reset)
}
pub struct PostmanClient {
api_key: String,
base_url: String,
transport: Box<dyn Transport>,
}
impl PostmanClient {
pub fn new(api_key: String, base_url: Option<String>) -> Self {
Self::with_transport(api_key, base_url, Box::new(CurlTransport::default()))
}
pub fn with_transport(
api_key: String,
base_url: Option<String>,
transport: Box<dyn Transport>,
) -> Self {
let base = base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
Self {
api_key,
base_url: base.trim_end_matches('/').to_string(),
transport,
}
}
fn call(&self, path_and_query: &str) -> Result<HttpResponse, ApiError> {
let url = format!("{}{}", self.base_url, path_and_query);
let res = self
.transport
.get(&url, &self.api_key)
.map_err(|e| ApiError::Transport(redact(&e, &self.api_key)))?;
if (200..300).contains(&res.status) {
return Ok(res);
}
let message = redact(&extract_error_message(&res.body), &self.api_key);
Err(match res.status {
401 => ApiError::Unauthorized,
403 => ApiError::Forbidden(message),
404 => ApiError::NotFound(message),
429 => {
let info = RateInfo::from_response(&res);
ApiError::RateLimited {
retry_after: info.retry_after.or(info.reset_secs),
monthly: error_name(&res.body)
.is_some_and(|n| n.eq_ignore_ascii_case("serviceLimitExhausted")),
}
}
status => ApiError::Http { status, message },
})
}
pub fn list_workspaces(
&self,
kinds: &[WorkspaceKind],
) -> Result<(Vec<WorkspaceSummary>, RateInfo), ApiError> {
let mut out: Vec<WorkspaceSummary> = Vec::new();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut cursor: Option<String> = None;
let mut last_rate = RateInfo::default();
for _ in 0..100 {
let mut query = String::from("/workspaces?limit=100");
if let Some(c) = &cursor {
query.push_str("&cursor=");
query.push_str(&percent_encode(c));
}
let res = self.call(&query)?;
last_rate = RateInfo::from_response(&res);
let body = parse_json(&res.body)?;
let items = array_field(&body, "workspaces");
let page_len = items.len();
for item in items {
let Some(id) = string_field(item, "id") else {
continue;
};
if !seen_ids.insert(id.clone()) {
continue;
}
out.push(WorkspaceSummary {
name: string_field(item, "name").unwrap_or_else(|| id.clone()),
kind: WorkspaceKind::parse(
&string_field(item, "type")
.or_else(|| string_field(item, "visibility"))
.unwrap_or_default(),
),
id,
});
}
let next = next_cursor(&body);
match next {
Some(c) if page_len > 0 && Some(&c) != cursor.as_ref() => cursor = Some(c),
_ => break,
}
}
if !kinds.is_empty() {
out.retain(|w| kinds.contains(&w.kind));
}
Ok((out, last_rate))
}
pub fn list_collections(
&self,
workspace_id: &str,
) -> Result<(Vec<ItemSummary>, RateInfo), ApiError> {
self.list_items(workspace_id, "collections")
}
pub fn list_environments(
&self,
workspace_id: &str,
) -> Result<(Vec<ItemSummary>, RateInfo), ApiError> {
let res = self.call(&format!(
"/environments?workspace={}",
percent_encode(workspace_id)
))?;
let rate = RateInfo::from_response(&res);
let body = parse_json(&res.body)?;
Ok((collect_items(&body, "environments"), rate))
}
fn list_items(
&self,
workspace_id: &str,
field: &str,
) -> Result<(Vec<ItemSummary>, RateInfo), ApiError> {
const PAGE: usize = 100;
let mut out: Vec<ItemSummary> = Vec::new();
let mut offset = 0usize;
let mut last_rate;
loop {
let res = self.call(&format!(
"/{field}?workspace={}&limit={PAGE}&offset={offset}",
percent_encode(workspace_id)
))?;
last_rate = RateInfo::from_response(&res);
let body = parse_json(&res.body)?;
let page = collect_items(&body, field);
let page_len = page.len();
out.extend(page);
let total = body
.get("meta")
.and_then(|m| m.get("total"))
.and_then(Value::as_u64)
.map(|t| t as usize);
match total {
Some(total) if out.len() < total && page_len > 0 => offset += PAGE,
_ => break,
}
}
Ok((out, last_rate))
}
pub fn get_collection(&self, uid: &str) -> Result<(String, RateInfo), ApiError> {
self.get_raw("collections", uid)
}
pub fn get_environment(&self, uid: &str) -> Result<(String, RateInfo), ApiError> {
self.get_raw("environments", uid)
}
fn get_raw(&self, field: &str, uid: &str) -> Result<(String, RateInfo), ApiError> {
let res = self.call(&format!("/{field}/{}", percent_encode(uid)))?;
let rate = RateInfo::from_response(&res);
let value = parse_json(&res.body)?;
if !value.is_object() {
return Err(ApiError::Parse(format!(
"expected a JSON object for {field}/{uid}"
)));
}
Ok((res.body, rate))
}
}
fn extract_error_message(body: &str) -> String {
if let Ok(v) = serde_json::from_str::<Value>(body)
&& let Some(err) = v.get("error")
{
if let Some(m) = err.get("message").and_then(Value::as_str) {
return m.to_string();
}
if let Some(m) = err.as_str() {
return m.to_string();
}
}
let trimmed = body.trim();
if trimmed.len() > 200 {
format!("{}…", &trimmed[..200])
} else {
trimmed.to_string()
}
}
fn error_name(body: &str) -> Option<String> {
serde_json::from_str::<Value>(body)
.ok()?
.get("error")?
.get("name")?
.as_str()
.map(str::to_string)
}
fn parse_json(body: &str) -> Result<Value, ApiError> {
serde_json::from_str::<Value>(body).map_err(|e| ApiError::Parse(e.to_string()))
}
fn array_field<'a>(v: &'a Value, name: &str) -> &'a [Value] {
v.get(name)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn string_field(v: &Value, name: &str) -> Option<String> {
v.get(name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn next_cursor(body: &Value) -> Option<String> {
body.get("meta")
.and_then(|m| string_field(m, "nextCursor"))
.or_else(|| string_field(body, "nextCursor"))
}
fn collect_items(body: &Value, field: &str) -> Vec<ItemSummary> {
array_field(body, field)
.iter()
.filter_map(|item| {
let uid = string_field(item, "uid").unwrap_or_default();
let id = string_field(item, "id").unwrap_or_default();
if uid.is_empty() && id.is_empty() {
return None;
}
let fallback = if uid.is_empty() { &id } else { &uid };
Some(ItemSummary {
name: string_field(item, "name").unwrap_or_else(|| fallback.clone()),
uid,
id,
})
})
.collect()
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pub fn sanitize_file_name(name: &str) -> String {
const RESERVED: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
let replaced: String = name
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
let collapsed = replaced.split_whitespace().collect::<Vec<_>>().join(" ");
let mut out: String = collapsed.trim().chars().take(180).collect();
while out.starts_with('.') {
out.remove(0);
}
let trimmed = out.trim_end_matches([' ', '.']);
out = trimmed.to_string();
if out.is_empty() {
return "untitled".to_string();
}
if RESERVED.iter().any(|r| out.eq_ignore_ascii_case(r)) {
out.insert(0, '_');
}
out
}
pub fn unique_file_name(stem: &str, extension: &str, taken: &mut HashSet<String>) -> String {
let base = sanitize_file_name(stem);
let mut candidate = format!("{base}.{extension}");
let mut n = 2;
while !taken.insert(candidate.to_lowercase()) {
candidate = format!("{base} ({n}).{extension}");
n += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct Recorder {
calls: Arc<Mutex<Vec<String>>>,
keys: Arc<Mutex<Vec<String>>>,
}
impl Recorder {
fn urls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
struct FakeTransport {
responses: Mutex<Vec<Result<HttpResponse, String>>>,
recorder: Recorder,
}
impl FakeTransport {
fn new(responses: Vec<Result<HttpResponse, String>>) -> (Box<Self>, Recorder) {
let recorder = Recorder::default();
(
Box::new(Self {
responses: Mutex::new(responses),
recorder: recorder.clone(),
}),
recorder,
)
}
}
impl Transport for FakeTransport {
fn get(&self, url: &str, api_key: &str) -> Result<HttpResponse, String> {
self.recorder.calls.lock().unwrap().push(url.to_string());
self.recorder.keys.lock().unwrap().push(api_key.to_string());
let mut queued = self.responses.lock().unwrap();
if queued.is_empty() {
return Err("no queued response".to_string());
}
queued.remove(0)
}
}
fn ok(body: &str) -> Result<HttpResponse, String> {
Ok(HttpResponse {
status: 200,
headers: Vec::new(),
body: body.to_string(),
})
}
fn ok_with(body: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, String> {
Ok(HttpResponse {
status: 200,
headers: headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
body: body.to_string(),
})
}
fn fail(status: u16, body: &str) -> Result<HttpResponse, String> {
Ok(HttpResponse {
status,
headers: Vec::new(),
body: body.to_string(),
})
}
fn client(responses: Vec<Result<HttpResponse, String>>) -> (PostmanClient, Recorder) {
let (transport, recorder) = FakeTransport::new(responses);
let c = PostmanClient::with_transport("secret-key".to_string(), None, transport);
(c, recorder)
}
#[test]
fn a_workspace_listing_is_read_into_summaries_with_its_visibility() {
let (c, _) = client(vec![ok(r#"{"workspaces":[
{"id":"w1","name":"Team API","type":"team"},
{"id":"w2","name":"Mine","type":"personal"}
]}"#)]);
let (ws, _) = c.list_workspaces(&[]).unwrap();
assert_eq!(
ws,
vec![
WorkspaceSummary {
id: "w1".into(),
name: "Team API".into(),
kind: WorkspaceKind::Team
},
WorkspaceSummary {
id: "w2".into(),
name: "Mine".into(),
kind: WorkspaceKind::Personal
},
]
);
}
#[test]
fn workspace_kinds_filter_client_side_and_default_selection_excludes_public() {
let body = r#"{"workspaces":[
{"id":"w1","name":"Mine","type":"personal"},
{"id":"w2","name":"Someone else's","type":"public"}
]}"#;
let (c, _) = client(vec![ok(body)]);
let (ws, _) = c
.list_workspaces(&WorkspaceKind::default_selection())
.unwrap();
assert_eq!(ws.len(), 1, "the public workspace is filtered out");
assert_eq!(ws[0].id, "w1");
}
#[test]
fn an_unknown_workspace_visibility_is_preserved_rather_than_dropped() {
let (c, _) = client(vec![ok(
r#"{"workspaces":[{"id":"w1","name":"New","type":"something-new"}]}"#,
)]);
let (ws, _) = c.list_workspaces(&[]).unwrap();
assert_eq!(ws[0].kind, WorkspaceKind::Other("something-new".into()));
}
#[test]
fn workspace_listing_follows_the_cursor_until_it_runs_out() {
let (c, _) = client(vec![
ok(
r#"{"workspaces":[{"id":"w1","name":"A","type":"team"}],"meta":{"nextCursor":"abc"}}"#,
),
ok(r#"{"workspaces":[{"id":"w2","name":"B","type":"team"}]}"#),
]);
let (ws, _) = c.list_workspaces(&[]).unwrap();
assert_eq!(ws.len(), 2);
}
#[test]
fn a_cursor_that_never_advances_terminates_instead_of_looping_forever() {
let page =
r#"{"workspaces":[{"id":"w1","name":"A","type":"team"}],"meta":{"nextCursor":"same"}}"#;
let (c, _) = client(vec![ok(page), ok(page), ok(page), ok(page)]);
let (ws, _) = c.list_workspaces(&[]).unwrap();
assert_eq!(ws.len(), 1, "the repeated page is not counted twice");
}
#[test]
fn collections_paginate_by_offset_until_meta_total_is_reached() {
let page1 = format!(
r#"{{"collections":[{}],"meta":{{"total":3,"limit":100,"offset":0}}}}"#,
(0..2)
.map(|i| format!(r#"{{"id":"c{i}","uid":"u-c{i}","name":"C{i}"}}"#))
.collect::<Vec<_>>()
.join(",")
);
let page2 = r#"{"collections":[{"id":"c2","uid":"u-c2","name":"C2"}],"meta":{"total":3,"limit":100,"offset":100}}"#;
let (c, rec) = client(vec![ok(&page1), ok(page2)]);
let (items, _) = c.list_collections("ws").unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[2].fetch_id(), "u-c2");
assert_eq!(
rec.urls(),
vec![
"https://api.postman.com/collections?workspace=ws&limit=100&offset=0",
"https://api.postman.com/collections?workspace=ws&limit=100&offset=100",
],
"the offset advances by a full page each time"
);
}
#[test]
fn workspace_paging_sends_the_cursor_it_was_given() {
let (c, rec) = client(vec![
ok(
r#"{"workspaces":[{"id":"w1","name":"A","type":"team"}],"meta":{"nextCursor":"c+2"}}"#,
),
ok(r#"{"workspaces":[{"id":"w2","name":"B","type":"team"}]}"#),
]);
c.list_workspaces(&[]).unwrap();
assert_eq!(
rec.urls(),
vec![
"https://api.postman.com/workspaces?limit=100",
"https://api.postman.com/workspaces?limit=100&cursor=c%2B2",
],
"the cursor is passed back percent-encoded"
);
}
#[test]
fn a_listing_without_meta_is_treated_as_one_complete_page() {
let (c, _) = client(vec![ok(
r#"{"collections":[{"id":"c0","uid":"u0","name":"Only"}]}"#,
)]);
let (items, _) = c.list_collections("ws").unwrap();
assert_eq!(items.len(), 1, "no second request is made without meta");
}
#[test]
fn environments_are_fetched_in_a_single_unpaginated_call() {
let (c, rec) = client(vec![ok(
r#"{"environments":[{"id":"e1","uid":"u-e1","name":"Prod"}]}"#,
)]);
let (items, _) = c.list_environments("ws-1").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Prod");
assert_eq!(
rec.urls(),
vec!["https://api.postman.com/environments?workspace=ws-1"],
"environments take exactly one call, with no paging parameters"
);
}
#[test]
fn an_item_with_no_uid_falls_back_to_its_id_for_fetching() {
let (c, _) = client(vec![ok(r#"{"collections":[{"id":"only-id","name":"C"}]}"#)]);
let (items, _) = c.list_collections("ws").unwrap();
assert_eq!(items[0].fetch_id(), "only-id");
}
#[test]
fn an_item_with_neither_id_nor_uid_is_skipped_rather_than_failing_the_listing() {
let (c, _) = client(vec![ok(
r#"{"collections":[{"name":"Broken"},{"id":"ok","name":"Fine"}]}"#,
)]);
let (items, _) = c.list_collections("ws").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "ok");
}
#[test]
fn a_fetched_collection_is_returned_byte_for_byte() {
let raw = r#"{"collection":{"info":{"name":"X"},"item":[],"unmodelled":42}}"#;
let (c, _) = client(vec![ok(raw)]);
let (body, _) = c.get_collection("u-1").unwrap();
assert_eq!(body, raw, "nothing is reserialised or dropped");
}
#[test]
fn a_non_json_body_with_a_success_status_is_a_parse_error_not_saved_to_disk() {
let (c, _) = client(vec![ok("<html>proxy error</html>")]);
assert!(matches!(c.get_collection("u-1"), Err(ApiError::Parse(_))));
}
#[test]
fn ids_are_percent_encoded_into_the_query_so_they_cannot_alter_it() {
let (c, rec) = client(vec![ok(r#"{"environments":[]}"#)]);
c.list_environments("a b/c&d").unwrap();
assert_eq!(
rec.urls(),
vec!["https://api.postman.com/environments?workspace=a%20b%2Fc%26d"]
);
}
#[test]
fn the_api_key_is_sent_as_the_x_api_key_header_value_on_every_call() {
let (c, rec) = client(vec![ok(r#"{"environments":[]}"#)]);
c.list_environments("ws").unwrap();
assert_eq!(rec.keys.lock().unwrap().as_slice(), ["secret-key"]);
}
#[test]
fn the_base_url_is_configurable_for_eu_tenants_and_its_trailing_slash_ignored() {
let (transport, rec) = FakeTransport::new(vec![ok(r#"{"workspaces":[]}"#)]);
let c =
PostmanClient::with_transport("k".into(), Some(format!("{EU_BASE_URL}/")), transport);
c.list_workspaces(&[]).unwrap();
assert_eq!(
rec.urls(),
vec![format!("{EU_BASE_URL}/workspaces?limit=100")],
"no doubled slash, and the EU host is used"
);
}
#[test]
fn http_statuses_map_onto_their_api_errors() {
let cases: Vec<(u16, &str, ApiError)> = vec![
(401, "{}", ApiError::Unauthorized),
(
403,
r#"{"error":{"message":"This feature isn't available in your region."}}"#,
ApiError::Forbidden("This feature isn't available in your region.".into()),
),
(
404,
r#"{"error":{"message":"not found"}}"#,
ApiError::NotFound("not found".into()),
),
];
for (status, body, expected) in cases {
let (c, _) = client(vec![fail(status, body)]);
assert_eq!(c.list_environments("ws").unwrap_err(), expected);
}
}
#[test]
fn a_per_minute_429_is_retryable_and_carries_the_servers_retry_after() {
let res = Ok(HttpResponse {
status: 429,
headers: vec![("x-ratelimit-retryafter".into(), "17".into())],
body: r#"{"error":{"name":"rateLimited","message":"slow down"}}"#.into(),
});
let (c, _) = client(vec![res]);
let err = c.list_environments("ws").unwrap_err();
assert_eq!(
err,
ApiError::RateLimited {
retry_after: Some(17),
monthly: false
}
);
assert!(err.is_retryable());
}
#[test]
fn the_monthly_usage_limit_is_not_retryable_because_waiting_will_not_clear_it() {
let (c, _) = client(vec![fail(
429,
r#"{"error":{"name":"serviceLimitExhausted","message":"out of calls"}}"#,
)]);
let err = c.list_environments("ws").unwrap_err();
assert_eq!(
err,
ApiError::RateLimited {
retry_after: None,
monthly: true
}
);
assert!(!err.is_retryable());
}
#[test]
fn a_transport_failure_never_leaks_the_api_key() {
let (transport, _) = FakeTransport::new(vec![Err(
"failed to connect to https://api.postman.com?key=secret-key".into(),
)]);
let c = PostmanClient::with_transport("secret-key".into(), None, transport);
let err = c.list_environments("ws").unwrap_err();
let text = err.to_string();
assert!(!text.contains("secret-key"), "got: {text}");
assert!(text.contains("***"), "got: {text}");
}
#[test]
fn an_error_body_that_is_not_postmans_envelope_is_truncated_not_dumped() {
let long = "x".repeat(500);
let (c, _) = client(vec![fail(500, &long)]);
match c.list_environments("ws").unwrap_err() {
ApiError::Http { status, message } => {
assert_eq!(status, 500);
assert!(
message.chars().count() <= 201,
"got {} chars",
message.len()
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn rate_headers_are_read_in_both_spellings_and_from_the_combined_header() {
let res = HttpResponse {
status: 200,
headers: vec![
("x-ratelimit-remaining".into(), "42".into()),
("ratelimit-remaining-month".into(), "812".into()),
(
"ratelimit".into(),
"limit=300, remaining=299, reset=52".into(),
),
],
body: String::new(),
};
let info = RateInfo::from_response(&res);
assert_eq!(info.remaining, Some(42), "the explicit header wins");
assert_eq!(info.limit, Some(300), "filled in from the combined header");
assert_eq!(info.reset_secs, Some(52));
assert_eq!(info.remaining_month, Some(812));
}
#[test]
fn an_epoch_style_reset_is_converted_back_to_seconds_from_now() {
let future = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 30;
let res = HttpResponse {
status: 200,
headers: vec![("x-ratelimit-reset".into(), future.to_string())],
body: String::new(),
};
let secs = RateInfo::from_response(&res).reset_secs.unwrap();
assert!((25..=35).contains(&secs), "got {secs}");
}
#[test]
fn a_listing_response_still_reports_its_rate_state() {
let (c, _) = client(vec![ok_with(
r#"{"environments":[]}"#,
&[("ratelimit-remaining", "7")],
)]);
let (_, rate) = c.list_environments("ws").unwrap();
assert_eq!(rate.remaining, Some(7));
}
#[test]
fn the_two_rate_buckets_differ_because_listing_calls_are_limited_far_harder() {
assert!(RateBucket::Strict.min_interval() > RateBucket::General.min_interval());
}
#[test]
fn filenames_lose_separators_control_characters_and_leading_dots() {
assert_eq!(sanitize_file_name("Orders/v2"), "Orders_v2");
assert_eq!(sanitize_file_name("a\u{0}b"), "a_b");
assert_eq!(sanitize_file_name(".hidden"), "hidden");
assert_eq!(sanitize_file_name("Orders API"), "Orders API");
assert_eq!(sanitize_file_name(" "), "untitled");
assert_eq!(sanitize_file_name("trailing. "), "trailing");
}
#[test]
fn a_windows_reserved_name_is_prefixed_so_the_import_works_everywhere() {
assert_eq!(sanitize_file_name("CON"), "_CON");
assert_eq!(sanitize_file_name("com1"), "_com1");
}
#[test]
fn a_very_long_name_is_capped_short_of_the_filesystem_limit() {
let out = sanitize_file_name(&"a".repeat(500));
assert_eq!(out.chars().count(), 180);
}
#[test]
fn two_collections_sharing_a_name_get_distinct_files_instead_of_overwriting() {
let mut taken = HashSet::new();
assert_eq!(
unique_file_name("Orders", "json", &mut taken),
"Orders.json"
);
assert_eq!(
unique_file_name("Orders", "json", &mut taken),
"Orders (2).json"
);
assert_eq!(
unique_file_name("Orders", "json", &mut taken),
"Orders (3).json"
);
}
#[test]
fn de_duplication_is_case_insensitive_because_some_filesystems_are() {
let mut taken = HashSet::new();
assert_eq!(
unique_file_name("Orders", "json", &mut taken),
"Orders.json"
);
assert_eq!(
unique_file_name("orders", "json", &mut taken),
"orders (2).json"
);
}
#[test]
fn a_header_line_splits_into_a_lowercased_name_and_trimmed_value() {
assert_eq!(
parse_header_line(b"RateLimit-Remaining: 42\r\n"),
Some(("ratelimit-remaining".to_string(), "42".to_string()))
);
assert_eq!(parse_header_line(b"HTTP/2 200\r\n"), None);
assert_eq!(parse_header_line(b"\r\n"), None);
}
#[test]
fn redaction_leaves_a_message_alone_when_there_is_no_key_to_remove() {
assert_eq!(redact("plain message", ""), "plain message");
}
}