use std::collections::HashMap;
macro_rules! info {
($($t:tt)*) => {};
}
macro_rules! debug {
($($t:tt)*) => {};
}
use super::types::*;
use crate::import::openapi::{
clean_pathway_name, ParsedOperation, ParsedParameter, ParsedRequestBody, ParsedSpec,
SecurityScheme,
};
pub fn convert_postman(collection: &PostmanCollection) -> Result<ParsedSpec, ConvertError> {
info!(
name = %collection.info.name,
items = collection.item.len(),
"📦 POSTMAN_CONVERT - Starting conversion"
);
let mut spec = ParsedSpec::new(
collection.info.name.clone(),
collection
.info
.version
.clone()
.unwrap_or_else(|| "1.0.0".to_string()),
);
spec.description = collection.info.description.clone();
for var in &collection.variable {
spec.variables.insert(var.key.clone(), var.value.clone());
}
if let Some(base_url) = extract_base_url(collection) {
spec.servers.push(crate::import::openapi::ServerInfo {
url: base_url,
description: None,
});
}
extract_security_schemes(&collection.auth, &mut spec.security_schemes);
if spec.security_schemes.is_empty() {
spec.security_schemes.insert(
"bearerAuth".to_string(),
SecurityScheme {
scheme_type: "http".to_string(),
description: Some("Inferred from Postman collection".to_string()),
name: None,
location: None,
scheme: Some("bearer".to_string()),
bearer_format: None,
},
);
}
let mut tags = Vec::new();
extract_operations(
&collection.item,
&mut spec.operations,
&mut tags,
"",
&spec.variables,
);
spec.tags = tags;
spec.update_counts();
info!(
operations = spec.operation_count,
paths = spec.path_count,
"✅ POSTMAN_CONVERT - Conversion complete"
);
Ok(spec)
}
fn extract_base_url(collection: &PostmanCollection) -> Option<String> {
for var in &collection.variable {
if (var.key == "baseUrl" || var.key == "base_url" || var.key == "host")
&& !var.value.is_empty()
{
return Some(var.value.clone());
}
}
if let Some(first_request) = find_first_request(&collection.item) {
if let Some(url) = &first_request.url {
if let Some(raw) = url.raw() {
if let Ok(parsed) = url::Url::parse(&raw.replace("{{", "").replace("}}", "")) {
return Some(format!("{}://{}", parsed.scheme(), parsed.host_str()?));
}
}
}
}
None
}
fn find_first_request(items: &[PostmanItem]) -> Option<&PostmanRequest> {
for item in items {
if let Some(ref request) = item.request {
return Some(request);
}
if let Some(found) = find_first_request(&item.item) {
return Some(found);
}
}
None
}
fn extract_security_schemes(
auth: &Option<PostmanAuth>,
schemes: &mut HashMap<String, SecurityScheme>,
) {
let Some(auth) = auth else { return };
match auth.auth_type.as_str() {
"bearer" => {
schemes.insert(
"bearerAuth".to_string(),
SecurityScheme {
scheme_type: "http".to_string(),
description: None,
name: None,
location: None,
scheme: Some("bearer".to_string()),
bearer_format: None,
},
);
}
"apikey" => {
let location = auth
.apikey
.iter()
.find(|kv| kv.key == "in")
.map(|kv| kv.value.clone())
.unwrap_or_else(|| "header".to_string());
let name = auth
.apikey
.iter()
.find(|kv| kv.key == "key")
.map(|kv| kv.value.clone())
.unwrap_or_else(|| "X-API-Key".to_string());
schemes.insert(
"apiKeyAuth".to_string(),
SecurityScheme {
scheme_type: "apiKey".to_string(),
description: None,
name: Some(name),
location: Some(location),
scheme: None,
bearer_format: None,
},
);
}
"basic" => {
schemes.insert(
"basicAuth".to_string(),
SecurityScheme {
scheme_type: "http".to_string(),
description: None,
name: None,
location: None,
scheme: Some("basic".to_string()),
bearer_format: None,
},
);
}
_ => {
debug!(auth_type = %auth.auth_type, "📦 POSTMAN_CONVERT - Unknown auth type");
}
}
}
fn extract_operations(
items: &[PostmanItem],
operations: &mut Vec<ParsedOperation>,
tags: &mut Vec<String>,
parent_tag: &str,
variables: &HashMap<String, String>,
) {
for item in items {
if item.is_folder() {
let tag = item.name.clone();
if !tags.contains(&tag) {
tags.push(tag.clone());
}
extract_operations(&item.item, operations, tags, &tag, variables);
} else if let Some(ref request) = item.request {
if let Some(op) = convert_request(item, request, parent_tag, variables) {
operations.push(op);
}
}
}
}
fn convert_request(
item: &PostmanItem,
request: &PostmanRequest,
tag: &str,
variables: &HashMap<String, String>,
) -> Option<ParsedOperation> {
let method = request.method.to_uppercase();
let path = extract_path(&request.url)?;
let sanitized_path = path
.trim_start_matches('/')
.replace(['{', '}'], "")
.replace(|c: char| !c.is_alphanumeric(), "_")
.to_lowercase();
let operation_id = if sanitized_path.is_empty() {
format!("{}_root", method.to_lowercase())
} else {
format!("{}_{}", method.to_lowercase(), sanitized_path)
};
let clean_tag = if tag.is_empty() {
String::new()
} else {
clean_pathway_name(tag)
};
let clean_name = clean_pathway_name(&item.name);
let display_name = if clean_tag.is_empty() {
clean_name
} else {
format!("{}--{}", clean_tag, clean_name)
};
let parameters = extract_parameters(request, &path, variables);
let request_body = extract_request_body(request);
let has_auth = request
.header
.as_ref()
.map(|h| {
h.iter()
.any(|hdr| hdr.key.to_lowercase() == "authorization")
})
.unwrap_or(false);
Some(ParsedOperation {
id: operation_id.clone(),
name: display_name,
description: item
.description
.clone()
.or_else(|| request.description.clone())
.unwrap_or_default(),
method,
path,
category: if tag.is_empty() {
"uncategorized".to_string()
} else {
tag.to_string()
},
tags: if tag.is_empty() {
vec![]
} else {
vec![tag.to_string()]
},
parameters,
request_body,
responses: HashMap::new(), security: if has_auth {
Some(vec![{
let mut m = HashMap::new();
m.insert("bearerAuth".to_string(), vec![]);
m
}])
} else {
None
},
deprecated: false,
summary: Some(item.name.clone()),
operation_id: Some(operation_id),
})
}
fn extract_path(url: &Option<PostmanUrl>) -> Option<String> {
let url = url.as_ref()?;
match url {
PostmanUrl::String(s) => {
let cleaned = s.replace("{{", "").replace("}}", "placeholder");
if let Ok(parsed) = url::Url::parse(&cleaned) {
let path = parsed.path().to_string();
Some(restore_path_params(s, &path))
} else {
let path = s.split('?').next().unwrap_or(s);
let path = path
.trim_start_matches("http://")
.trim_start_matches("https://");
let path = path.split('/').skip(1).collect::<Vec<_>>().join("/");
Some(format!("/{}", restore_path_params(s, &path)))
}
}
PostmanUrl::Object(obj) => {
if obj.path.is_empty() {
return obj
.raw
.as_ref()
.and_then(|r| extract_path(&Some(PostmanUrl::String(r.clone()))));
}
let path_parts: Vec<String> = obj
.path
.iter()
.map(|p| {
if let Some(stripped) = p.strip_prefix(':') {
format!("{{{}}}", stripped)
} else if p.starts_with("{{") && p.ends_with("}}") {
format!("{{{}}}", &p[2..p.len() - 2])
} else {
p.clone()
}
})
.collect();
Some(format!("/{}", path_parts.join("/")))
}
}
}
fn restore_path_params(original: &str, path: &str) -> String {
let mut result = path.to_string();
let re = regex::Regex::new(r"\{\{(\w+)\}\}").unwrap();
for cap in re.captures_iter(original) {
let var_name = &cap[1];
result = result.replace("placeholder", &format!("{{{}}}", var_name));
}
let colon_re = regex::Regex::new(r":(\w+)").unwrap();
for cap in colon_re.captures_iter(original) {
let var_name = &cap[1];
if !result.contains(&format!("{{{}}}", var_name)) {
result = result.replacen(&format!(":{}", var_name), &format!("{{{}}}", var_name), 1);
}
}
result
}
fn extract_parameters(
request: &PostmanRequest,
path: &str,
_variables: &HashMap<String, String>,
) -> Vec<ParsedParameter> {
let mut params = Vec::new();
let path_param_re = regex::Regex::new(r"\{(\w+)\}").unwrap();
for cap in path_param_re.captures_iter(path) {
let param_name = cap[1].to_string();
let description = if let Some(PostmanUrl::Object(obj)) = &request.url {
obj.variable
.iter()
.find(|v| v.key == param_name)
.map(|v| v.value.clone())
} else {
None
};
let schema = Some(infer_schema_from_value(description.as_deref()));
params.push(ParsedParameter {
name: param_name,
location: "path".to_string(),
required: true,
description,
schema,
});
}
if let Some(url) = &request.url {
for q in url.query() {
if q.disabled {
continue;
}
params.push(ParsedParameter {
name: q.key.clone(),
location: "query".to_string(),
required: false,
description: None,
schema: Some(infer_schema_from_value(Some(&q.value))),
});
}
}
if let Some(headers) = &request.header {
for h in headers {
if h.disabled {
continue;
}
let key_lower = h.key.to_lowercase();
if key_lower == "authorization" || key_lower == "content-type" || key_lower == "accept"
{
continue;
}
params.push(ParsedParameter {
name: h.key.clone(),
location: "header".to_string(),
required: false,
description: Some(h.value.clone()),
schema: Some(serde_json::json!({"type": "string"})),
});
}
}
params
}
fn infer_schema_from_value(value: Option<&str>) -> serde_json::Value {
let Some(value) = value else {
return serde_json::json!({"type": "string"});
};
if value.parse::<i64>().is_ok() {
return serde_json::json!({"type": "integer"});
}
if value.parse::<f64>().is_ok() {
return serde_json::json!({"type": "number"});
}
if value == "true" || value == "false" {
return serde_json::json!({"type": "boolean"});
}
serde_json::json!({"type": "string"})
}
fn extract_request_body(request: &PostmanRequest) -> Option<ParsedRequestBody> {
let body = request.body.as_ref()?;
let mode = body.mode.as_deref()?;
match mode {
"raw" => {
let raw = body.raw.as_ref()?;
let schema = if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
Some(generate_schema_from_example(&parsed))
} else {
Some(serde_json::json!({"type": "string"}))
};
Some(ParsedRequestBody {
required: true,
content_type: Some("application/json".to_string()),
description: None,
schema,
})
}
"urlencoded" => {
let properties: serde_json::Map<String, serde_json::Value> = body
.urlencoded
.iter()
.filter(|f| !f.disabled)
.map(|f| {
(
f.key.clone(),
serde_json::json!({
"type": "string",
"description": f.value
}),
)
})
.collect();
Some(ParsedRequestBody {
required: true,
content_type: Some("application/x-www-form-urlencoded".to_string()),
description: None,
schema: Some(serde_json::json!({
"type": "object",
"properties": properties
})),
})
}
"formdata" => {
let properties: serde_json::Map<String, serde_json::Value> = body
.formdata
.iter()
.filter(|f| !f.disabled)
.map(|f| {
let schema = if f.param_type.as_deref() == Some("file") {
serde_json::json!({
"type": "string",
"format": "binary"
})
} else {
serde_json::json!({
"type": "string",
"description": f.value
})
};
(f.key.clone(), schema)
})
.collect();
Some(ParsedRequestBody {
required: true,
content_type: Some("multipart/form-data".to_string()),
description: None,
schema: Some(serde_json::json!({
"type": "object",
"properties": properties
})),
})
}
_ => None,
}
}
fn generate_schema_from_example(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Null => serde_json::json!({"type": "null"}),
serde_json::Value::Bool(_) => serde_json::json!({"type": "boolean"}),
serde_json::Value::Number(n) => {
if n.is_i64() || n.is_u64() {
serde_json::json!({"type": "integer"})
} else {
serde_json::json!({"type": "number"})
}
}
serde_json::Value::String(_) => serde_json::json!({"type": "string"}),
serde_json::Value::Array(arr) => {
let items = arr
.first()
.map(generate_schema_from_example)
.unwrap_or_else(|| serde_json::json!({"type": "object"}));
serde_json::json!({
"type": "array",
"items": items
})
}
serde_json::Value::Object(obj) => {
let properties: serde_json::Map<String, serde_json::Value> = obj
.iter()
.map(|(k, v)| (k.clone(), generate_schema_from_example(v)))
.collect();
serde_json::json!({
"type": "object",
"properties": properties
})
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConvertError {
#[error("Invalid JSON: {0}")]
InvalidJson(String),
#[error("Invalid Postman collection: {0}")]
InvalidCollection(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_postman(json: &str) -> Result<PostmanCollection, ConvertError> {
serde_json::from_str(json).map_err(|e| ConvertError::InvalidCollection(e.to_string()))
}
const SAMPLE_COLLECTION: &str = r#"
{
"info": {
"name": "Sample API",
"description": "A sample API collection",
"version": "1.0.0",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Users",
"item": [
{
"name": "List Users",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/users",
"host": ["{{baseUrl}}"],
"path": ["users"],
"query": [
{"key": "limit", "value": "10"}
]
}
}
},
{
"name": "Get User",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/users/:id",
"host": ["{{baseUrl}}"],
"path": ["users", ":id"],
"variable": [
{"key": "id", "value": "123"}
]
}
}
}
]
}
],
"variable": [
{"key": "baseUrl", "value": "https://api.example.com"}
]
}
"#;
#[test]
fn test_parse_postman_collection() {
let collection = parse_postman(SAMPLE_COLLECTION).unwrap();
assert_eq!(collection.info.name, "Sample API");
assert_eq!(collection.item.len(), 1);
}
#[test]
fn test_convert_postman() {
let collection = parse_postman(SAMPLE_COLLECTION).unwrap();
let spec = convert_postman(&collection).unwrap();
assert_eq!(spec.title, "Sample API");
assert_eq!(spec.operation_count, 2);
assert_eq!(spec.tags.len(), 1);
assert!(spec.tags.contains(&"Users".to_string()));
}
}