use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::converter;
use crate::converter::{PostmanCollection, v1_0_0, v2_0_0, v2_1_0};
use crate::file::confine_to_dir;
use crate::json::{
Header, JsonContent, Query, RequestBody, Response, Url, Variable, method_from_str,
};
fn slugify(name: &str) -> String {
let mut out = String::new();
let mut prev_sep = true; for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.extend(ch.to_lowercase());
prev_sep = false;
} else if !prev_sep {
out.push('_');
prev_sep = true;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"unnamed".to_string()
} else {
out
}
}
fn unique_slug(taken: &mut HashSet<String>, base: &str) -> String {
if taken.insert(base.to_string()) {
return base.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{base}_{n}");
if taken.insert(candidate.clone()) {
return candidate;
}
n += 1;
}
}
fn split_raw_url(raw: &str) -> Url {
let raw = raw.split('#').next().unwrap_or(raw);
let (protocol, rest) = match raw.split_once("://") {
Some((scheme, rest)) => (scheme.to_string(), rest),
None => (String::new(), raw),
};
let (host_path, query_str) = match rest.split_once('?') {
Some((hp, q)) => (hp, Some(q)),
None => (rest, None),
};
let (host, path_str) = match host_path.split_once('/') {
Some((h, p)) => (h.to_string(), p),
None => (host_path.to_string(), ""),
};
let segments: Vec<String> = path_str
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let variables: Vec<Variable> = segments
.iter()
.filter_map(|seg| placeholder_name(seg))
.map(|name| Variable {
name,
dtype: "string".to_string(),
description: None,
required: true,
})
.collect();
let query: Vec<Query> = match query_str {
Some(q) if !q.is_empty() => q
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
Query {
name: k.to_string(),
value: v.to_string(),
description: None,
required: false,
}
})
.collect(),
_ => Vec::new(),
};
Url {
protocol,
host,
path: if segments.is_empty() {
None
} else {
Some(segments)
},
query: if query.is_empty() { None } else { Some(query) },
variable: if variables.is_empty() {
None
} else {
Some(variables)
},
}
}
fn placeholder_name(segment: &str) -> Option<String> {
if let Some(name) = segment.strip_prefix(':') {
return (!name.is_empty()).then(|| name.to_string());
}
if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
return (!inner.is_empty()).then(|| inner.to_string());
}
None
}
struct RawRequest {
name: String,
description: Option<String>,
method: String,
raw_url: String,
headers: Vec<(String, String)>,
body: Option<String>,
responses: Vec<(u16, String, Option<String>)>,
}
fn body_example(raw: Option<&str>) -> Option<serde_json::Value> {
let text = raw?.trim();
if text.is_empty() {
return None;
}
match serde_json::from_str::<serde_json::Value>(text) {
Ok(value) => Some(value),
Err(_) => Some(serde_json::Value::String(text.to_string())),
}
}
fn build_contract(raw: RawRequest) -> JsonContent {
let headers = raw
.headers
.into_iter()
.map(|(name, value)| Header { name, value })
.collect();
let request = raw.body.as_deref().and_then(|text| {
body_example(Some(text)).map(|example| RequestBody {
dtype: "object".to_string(),
schema: None,
example: Some(example),
})
});
let responses = raw
.responses
.into_iter()
.map(|(code, status, body)| Response {
code,
description: status,
dtype: "object".to_string(),
schema: Vec::new(),
example: body_example(body.as_deref()),
})
.collect();
JsonContent {
name: raw.name,
description: raw.description,
method: method_from_str(&raw.method),
url: split_raw_url(&raw.raw_url),
headers,
request,
responses,
}
}
pub(crate) struct MappedContract {
pub rel_path: PathBuf,
pub contract: JsonContent,
pub warning: Option<String>,
}
fn method_warning(method: &str, name: &str) -> Option<String> {
let upper = method.to_uppercase();
if matches!(
upper.as_str(),
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
) {
None
} else {
Some(format!(
"request {name:?} uses method {upper}, unsupported by apic — imported as GET"
))
}
}
fn map_v2_1(spec: &v2_1_0::Spec) -> Vec<MappedContract> {
let mut out = Vec::new();
walk_v2_1(&spec.item, Path::new(""), &mut out);
out
}
fn walk_v2_1(items: &[v2_1_0::Items], dir: &Path, out: &mut Vec<MappedContract>) {
let mut taken = HashSet::new();
for item in items {
match item {
v2_1_0::Items::ItemGroup(group) => {
let name = group.name.as_deref().unwrap_or("folder");
let slug = unique_slug(&mut taken, &slugify(name));
let child_dir = dir.join(&slug);
walk_v2_1(&group.item, &child_dir, out);
}
v2_1_0::Items::Item(it) => {
let raw = raw_request_v2_1(it);
let slug = unique_slug(&mut taken, &slugify(&raw.name));
let warning = method_warning(&raw.method, &raw.name);
out.push(MappedContract {
rel_path: dir.join(format!("{slug}.json")),
contract: build_contract(raw),
warning,
});
}
}
}
}
fn raw_request_v2_1(item: &v2_1_0::Item) -> RawRequest {
let req = &item.request;
let name = item.name.clone().unwrap_or_else(|| "request".to_string());
let description = item.description.as_ref().and_then(description_text_v2_1);
let method = req.method.clone().unwrap_or_else(|| "GET".to_string());
let raw_url = req.url.as_ref().map(url_raw_v2_1).unwrap_or_default();
let headers = match &req.header {
Some(list) => list
.iter()
.filter(|h| !h.disabled.unwrap_or(false))
.map(|h| (h.key.clone(), h.value.clone()))
.collect::<Vec<_>>(),
None => Vec::new(),
};
let body = req.body.as_ref().and_then(|b| b.raw.clone());
let responses = item
.response
.as_ref()
.map(|rs| {
rs.iter()
.map(|r| {
let code = r.code.unwrap_or(0) as u16;
let status = r.status.clone().unwrap_or_else(|| code.to_string());
(code, status, r.body.clone())
})
.collect()
})
.unwrap_or_default();
RawRequest {
name,
description,
method,
raw_url,
headers,
body,
responses,
}
}
fn description_text_v2_1(d: &v2_1_0::DescriptionUnion) -> Option<String> {
match d {
v2_1_0::DescriptionUnion::String(s) => Some(s.clone()),
v2_1_0::DescriptionUnion::Description(desc) => desc.content.clone(),
}
}
fn url_raw_v2_1(url: &v2_1_0::Url) -> String {
match url {
v2_1_0::Url::String(s) => s.clone(),
v2_1_0::Url::UrlClass(u) => u.raw.clone().unwrap_or_default(),
}
}
fn map_v2_0(spec: &v2_0_0::Spec) -> Vec<MappedContract> {
let mut out = Vec::new();
walk_v2_0(&spec.item, Path::new(""), &mut out);
out
}
fn walk_v2_0(items: &[v2_0_0::Items], dir: &Path, out: &mut Vec<MappedContract>) {
let mut taken = HashSet::new();
for item in items {
match item {
v2_0_0::Items::ItemGroup(group) => {
let name = group.name.as_deref().unwrap_or("folder");
let slug = unique_slug(&mut taken, &slugify(name));
let child_dir = dir.join(&slug);
walk_v2_0(&group.item, &child_dir, out);
}
v2_0_0::Items::Item(it) => {
let raw = raw_request_v2_0(it);
let slug = unique_slug(&mut taken, &slugify(&raw.name));
let warning = method_warning(&raw.method, &raw.name);
out.push(MappedContract {
rel_path: dir.join(format!("{slug}.json")),
contract: build_contract(raw),
warning,
});
}
}
}
}
fn raw_request_v2_0(item: &v2_0_0::Item) -> RawRequest {
let req = &item.request;
let name = item.name.clone().unwrap_or_else(|| "request".to_string());
let description = item.description.as_ref().and_then(description_text_v2_0);
let method = req.method.clone().unwrap_or_else(|| "GET".to_string());
let raw_url = req.url.as_ref().map(url_raw_v2_0).unwrap_or_default();
let headers = match &req.header {
Some(list) => list
.iter()
.filter(|h| !h.disabled.unwrap_or(false))
.map(|h| (h.key.clone(), h.value.clone()))
.collect::<Vec<_>>(),
None => Vec::new(),
};
let body = req.body.as_ref().and_then(|b| b.raw.clone());
let responses = item
.response
.as_ref()
.map(|rs| {
rs.iter()
.map(|r| {
let code = r.code.unwrap_or(0) as u16;
let status = r.status.clone().unwrap_or_else(|| code.to_string());
(code, status, r.body.clone())
})
.collect()
})
.unwrap_or_default();
RawRequest {
name,
description,
method,
raw_url,
headers,
body,
responses,
}
}
fn description_text_v2_0(d: &v2_0_0::DescriptionUnion) -> Option<String> {
match d {
v2_0_0::DescriptionUnion::String(s) => Some(s.clone()),
v2_0_0::DescriptionUnion::Description(desc) => desc.content.clone(),
}
}
fn url_raw_v2_0(url: &v2_0_0::Url) -> String {
match url {
v2_0_0::Url::String(s) => s.clone(),
v2_0_0::Url::UrlClass(u) => u.raw.clone().unwrap_or_default(),
}
}
fn map_v1(spec: &v1_0_0::Spec) -> Vec<MappedContract> {
use std::collections::HashMap;
let by_id: HashMap<&str, &v1_0_0::Request> =
spec.requests.iter().map(|r| (r.id.as_str(), r)).collect();
let mut out = Vec::new();
let mut placed: HashSet<&str> = HashSet::new();
if let Some(folders) = &spec.folders {
for folder in folders {
let dir = PathBuf::from(slugify(&folder.name));
let mut taken = HashSet::new();
for id in &folder.order {
if let Some(req) = by_id.get(id.as_str()) {
placed.insert(id.as_str());
push_v1_request(req, &dir, &mut taken, &mut out);
}
}
}
}
let mut root_taken = HashSet::new();
for req in &spec.requests {
if !placed.contains(req.id.as_str()) {
push_v1_request(req, Path::new(""), &mut root_taken, &mut out);
}
}
out
}
fn push_v1_request(
req: &v1_0_0::Request,
dir: &Path,
taken: &mut HashSet<String>,
out: &mut Vec<MappedContract>,
) {
let raw = raw_request_v1(req);
let slug = unique_slug(taken, &slugify(&raw.name));
let warning = method_warning(&raw.method, &raw.name);
out.push(MappedContract {
rel_path: dir.join(format!("{slug}.json")),
contract: build_contract(raw),
warning,
});
}
fn raw_request_v1(req: &v1_0_0::Request) -> RawRequest {
let raw_url = req.url.clone();
let body = req
.raw_mode_data
.as_ref()
.and_then(|v| v.as_str())
.map(str::to_string);
RawRequest {
name: req.name.clone(),
description: req.description.clone(),
method: req.method.clone(),
raw_url,
headers: parse_v1_headers(&req.headers),
body,
responses: Vec::new(),
}
}
fn parse_v1_headers(headers: &str) -> Vec<(String, String)> {
headers
.lines()
.filter_map(|line| line.split_once(':'))
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect()
}
fn map(collection: &PostmanCollection) -> Vec<MappedContract> {
match collection {
PostmanCollection::V1_0_0(spec) => map_v1(spec),
PostmanCollection::V2_0_0(spec) => map_v2_0(spec),
PostmanCollection::V2_1_0(spec) => map_v2_1(spec),
}
}
fn write_contracts(dest_base: &Path, mapped: &[MappedContract]) -> Result<usize, String> {
let mut written = 0usize;
for item in mapped {
let path = confine_to_dir(dest_base, &item.rel_path)?;
if path.exists() {
return Err(format!(
"{} already exists; refusing to overwrite",
path.display()
));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&item.contract)
.map_err(|err| format!("failed to serialize contract: {err}"))?;
fs::write(&path, json)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
written += 1;
}
Ok(written)
}
pub fn run(collection_path: &Path, dest_base: &Path) -> Result<(), String> {
let collection = converter::from_path(collection_path)?;
let mapped = map(&collection);
if mapped.is_empty() {
return Err("collection contained no convertible requests".to_string());
}
let count = write_contracts(dest_base, &mapped)?;
let warnings: Vec<&str> = mapped.iter().filter_map(|m| m.warning.as_deref()).collect();
for warning in &warnings {
eprintln!("warning: {warning}");
}
let suffix = match warnings.len() {
0 => String::new(),
1 => " (1 warning)".to_string(),
n => format!(" ({n} warnings)"),
};
println!(
"Converted {count} contract(s) into {}{suffix}",
dest_base.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_basic() {
assert_eq!(slugify("Get User By ID"), "get_user_by_id");
assert_eq!(slugify("create/login!!"), "create_login");
assert_eq!(slugify(" spaced "), "spaced");
assert_eq!(slugify(""), "unnamed");
assert_eq!(slugify("***"), "unnamed");
}
#[test]
fn slug_unique_appends_suffix() {
let mut taken = HashSet::new();
assert_eq!(unique_slug(&mut taken, "user"), "user");
assert_eq!(unique_slug(&mut taken, "user"), "user_2");
assert_eq!(unique_slug(&mut taken, "user"), "user_3");
assert_eq!(unique_slug(&mut taken, "auth"), "auth");
}
#[test]
fn url_full() {
let u = split_raw_url("https://api.example.com/v1/users/:id?limit=10&page=2");
assert_eq!(u.protocol, "https");
assert_eq!(u.host, "api.example.com");
assert_eq!(
u.path,
Some(vec!["v1".into(), "users".into(), ":id".into()])
);
let q = u.query.unwrap();
assert_eq!(q.len(), 2);
assert_eq!(q[0].name, "limit");
assert_eq!(q[0].value, "10");
let vars = u.variable.unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].name, "id");
}
#[test]
fn url_template_host_no_scheme() {
let u = split_raw_url("{{baseUrl}}/auth/login");
assert_eq!(u.protocol, "");
assert_eq!(u.host, "{{baseUrl}}");
assert_eq!(u.path, Some(vec!["auth".into(), "login".into()]));
assert!(u.query.is_none());
assert!(u.variable.is_none());
}
#[test]
fn url_host_only() {
let u = split_raw_url("https://example.com");
assert_eq!(u.host, "example.com");
assert!(u.path.is_none());
}
#[test]
fn build_maps_core_fields() {
let raw = RawRequest {
name: "Get User".into(),
description: Some("fetch a user".into()),
method: "get".into(),
raw_url: "https://api.example.com/users/:id".into(),
headers: vec![("Accept".into(), "application/json".into())],
body: None,
responses: vec![(200, "200 OK".into(), Some("{\"id\":1}".into()))],
};
let c = build_contract(raw);
assert_eq!(c.name, "Get User");
assert!(matches!(c.method, crate::json::Method::GET));
assert_eq!(c.headers.len(), 1);
assert_eq!(c.headers[0].name, "Accept");
assert_eq!(c.responses.len(), 1);
assert_eq!(c.responses[0].code, 200);
assert!(c.responses[0].example.is_some());
assert!(c.request.is_none());
}
#[test]
fn build_body_parses_json_else_string() {
assert_eq!(
body_example(Some("{\"a\":1}")),
Some(serde_json::json!({"a": 1}))
);
assert_eq!(
body_example(Some("plain text")),
Some(serde_json::Value::String("plain text".into()))
);
assert_eq!(body_example(Some(" ")), None);
assert_eq!(body_example(None), None);
}
fn v2_1_collection(json: &str) -> v2_1_0::Spec {
match crate::converter::from_slice(json.as_bytes()).unwrap() {
PostmanCollection::V2_1_0(s) => s,
_ => panic!("expected a v2.1 collection"),
}
}
#[test]
fn v2_1_mirrors_folders_and_maps_request() {
let json = r#"{
"info": { "name": "X", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{ "name": "users", "item": [
{ "name": "Get User",
"request": { "method": "GET", "header": [],
"url": { "raw": "https://api.example.com/users/:id" } },
"response": [] }
] }
]
}"#;
let mapped = map_v2_1(&v2_1_collection(json));
assert_eq!(mapped.len(), 1);
assert_eq!(
mapped[0].rel_path,
std::path::Path::new("users").join("get_user.json")
);
assert!(matches!(
mapped[0].contract.method,
crate::json::Method::GET
));
}
#[test]
fn v2_1_unsupported_method_warns_supported_ones_do_not() {
let json = r#"{
"info": { "name": "X", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{ "name": "Trace",
"request": { "method": "TRACE", "url": { "raw": "https://api.example.com/x" } },
"response": [] },
{ "name": "Preflight",
"request": { "method": "OPTIONS", "url": { "raw": "https://api.example.com/x" } },
"response": [] }
]
}"#;
let mapped = map_v2_1(&v2_1_collection(json));
assert_eq!(mapped.len(), 2);
assert!(matches!(
mapped[0].contract.method,
crate::json::Method::GET
));
let warning = mapped[0].warning.as_deref().expect("warning for TRACE");
assert!(warning.contains("TRACE"), "{warning}");
assert!(warning.contains("Trace"), "{warning}");
assert!(matches!(
mapped[1].contract.method,
crate::json::Method::OPTIONS
));
assert!(mapped[1].warning.is_none());
}
#[test]
fn v2_1_auth_block_is_ignored() {
let json = r#"{
"info": { "name": "X", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{ "name": "Empty",
"request": { "method": "POST", "auth": {},
"url": { "raw": "https://api.example.com/login" } },
"response": [] },
{ "name": "Bearer",
"request": { "method": "GET",
"auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "abc123" } ] },
"url": { "raw": "https://api.example.com/me" } },
"response": [] }
]
}"#;
let mapped = map_v2_1(&v2_1_collection(json));
assert_eq!(mapped.len(), 2);
for contract in mapped.iter().map(|m| &m.contract) {
assert!(contract.headers.iter().all(|h| h.name != "Authorization"));
}
}
#[test]
fn v1_folders_group_requests_by_id() {
let json = r#"{
"id": "col1", "name": "Legacy", "order": [],
"folders": [ { "id": "f1", "name": "Users", "description": "", "order": ["r1"] } ],
"requests": [
{ "id": "r1", "name": "List Users", "method": "GET", "headers": "",
"url": "https://api.example.com/users", "collectionId": "col1" }
]
}"#;
let spec = match crate::converter::from_slice(json.as_bytes()).unwrap() {
PostmanCollection::V1_0_0(s) => s,
_ => panic!("expected a v1 collection"),
};
let mapped = map_v1(&spec);
assert_eq!(mapped.len(), 1);
assert_eq!(
mapped[0].rel_path,
std::path::Path::new("users").join("list_users.json")
);
}
fn v2_0_collection(json: &str) -> v2_0_0::Spec {
match crate::converter::from_slice(json.as_bytes()).unwrap() {
PostmanCollection::V2_0_0(s) => s,
_ => panic!("expected a v2.0 collection"),
}
}
#[test]
fn v2_0_mirrors_folders_and_maps_request() {
let json = r#"{
"info": { "name": "X", "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" },
"item": [
{ "name": "users", "item": [
{ "name": "Get User",
"request": { "method": "GET", "header": [],
"url": { "raw": "https://api.example.com/users/:id" } },
"response": [] }
] }
]
}"#;
let mapped = map_v2_0(&v2_0_collection(json));
assert_eq!(mapped.len(), 1);
assert_eq!(
mapped[0].rel_path,
std::path::Path::new("users").join("get_user.json")
);
assert!(matches!(
mapped[0].contract.method,
crate::json::Method::GET
));
}
#[test]
fn url_with_port_and_tricky_query() {
let u = split_raw_url("https://api.example.com:8080/v1/items?filter=a=b&q=x%20y");
assert_eq!(u.protocol, "https");
assert_eq!(u.host, "api.example.com:8080");
assert_eq!(u.path, Some(vec!["v1".into(), "items".into()]));
let q = u.query.unwrap();
assert_eq!(q.len(), 2);
assert_eq!(q[0].name, "filter");
assert_eq!(q[0].value, "a=b");
assert_eq!(q[1].name, "q");
assert_eq!(q[1].value, "x%20y");
}
#[test]
fn v1_non_string_body_is_dropped() {
let json = r#"{
"id": "col1", "name": "Legacy", "order": ["r1"],
"requests": [
{ "id": "r1", "name": "Make", "method": "POST", "headers": "",
"url": "https://api.example.com/things",
"rawModeData": ["chunk-a", "chunk-b"], "collectionId": "col1" }
]
}"#;
let spec = match crate::converter::from_slice(json.as_bytes()).unwrap() {
PostmanCollection::V1_0_0(s) => s,
_ => panic!("expected a v1 collection"),
};
let mapped = map_v1(&spec);
assert_eq!(mapped.len(), 1);
assert!(mapped[0].contract.request.is_none());
}
#[test]
fn folder_and_request_same_name_do_not_collide() {
let json = r#"{
"info": { "name": "X", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{ "name": "users", "item": [
{ "name": "Get",
"request": { "method": "GET", "url": { "raw": "https://api.example.com/users" } },
"response": [] }
] },
{ "name": "users",
"request": { "method": "GET", "url": { "raw": "https://api.example.com/users" } },
"response": [] }
]
}"#;
let mapped = map_v2_1(&v2_1_collection(json));
assert_eq!(mapped.len(), 2);
let paths: Vec<String> = mapped
.iter()
.map(|m| m.rel_path.to_string_lossy().replace('\\', "/"))
.collect();
assert!(paths.contains(&"users/get.json".to_string()), "{paths:?}");
assert!(paths.contains(&"users_2.json".to_string()), "{paths:?}");
}
#[test]
fn write_creates_nested_files_and_refuses_overwrite() {
let base = std::env::temp_dir().join("apic_convert_write_test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mapped = vec![MappedContract {
rel_path: std::path::Path::new("users").join("get_user.json"),
contract: build_contract(RawRequest {
name: "Get User".into(),
description: None,
method: "GET".into(),
raw_url: "https://api.example.com/users/1".into(),
headers: vec![],
body: None,
responses: vec![],
}),
warning: None,
}];
let n = write_contracts(&base, &mapped).unwrap();
assert_eq!(n, 1);
assert!(base.join("users").join("get_user.json").is_file());
let err = write_contracts(&base, &mapped).unwrap_err();
assert!(err.contains("already exists"));
std::fs::remove_dir_all(&base).unwrap();
}
}