use std::collections::HashSet;
use std::path::PathBuf;
use serde_json::Value;
pub struct Options {
pub spec: String,
pub out: PathBuf,
pub base_url: Option<String>,
pub normalize: bool,
pub edge_cases: bool,
pub force: bool,
}
pub fn run(opts: &Options) -> Result<(usize, usize), String> {
let text = if opts.spec.starts_with("http://") || opts.spec.starts_with("https://") {
let req = super::Request {
method: "GET".to_string(),
url: opts.spec.clone(),
headers: vec![("accept".to_string(), "application/json".to_string())],
body: None,
insecure: false,
};
super::send(&req).and_then(|r| {
if (200..300).contains(&r.status) {
Ok(r.body)
} else {
Err(format!("fetching spec: HTTP {}", r.status))
}
})?
} else {
std::fs::read_to_string(&opts.spec).map_err(|e| format!("read {}: {e}", opts.spec))?
};
let spec: Value = serde_json::from_str(&text).map_err(|e| format!("parse spec: {e}"))?;
let base_url = opts
.base_url
.clone()
.or_else(|| {
spec.get("servers")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(|s| s.get("url"))
.and_then(Value::as_str)
.map(str::to_string)
})
.or_else(|| {
let host = spec.get("host").and_then(Value::as_str)?;
let base = spec.get("basePath").and_then(Value::as_str).unwrap_or("");
let scheme = spec
.get("schemes")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_str)
.unwrap_or("https");
Some(format!("{scheme}://{host}{base}"))
})
.unwrap_or_else(|| "{{BASE_URL}}".to_string());
let base_url = base_url.trim_end_matches('/').to_string();
let paths = spec
.get("paths")
.and_then(Value::as_object)
.ok_or("spec has no `paths`")?;
std::fs::create_dir_all(&opts.out).map_err(|e| format!("mkdir {}: {e}", opts.out.display()))?;
let mut count = 0usize;
let mut skipped = 0usize;
let mut login_by_tag: std::collections::BTreeMap<String, ChainStep> =
std::collections::BTreeMap::new();
let mut requests_by_tag: std::collections::BTreeMap<String, Vec<ChainStep>> =
std::collections::BTreeMap::new();
for (path, methods) in paths {
let Some(methods) = methods.as_object() else {
continue;
};
for (method, op) in methods {
if !is_http_method(method) {
continue;
}
let folder = op
.get("tags")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_str)
.map(sanitize)
.unwrap_or_else(|| "untagged".to_string());
let dir = opts.out.join(&folder);
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let file_base = op
.get("operationId")
.and_then(Value::as_str)
.map(sanitize)
.unwrap_or_else(|| {
sanitize(&format!("{}-{}", method.to_lowercase(), path))
});
let hints = login_extract_hints(path, &method.to_uppercase(), op, &spec);
let named = collect_named_examples(op);
if named.is_empty() {
let curl = render_curl(
&base_url,
path,
method,
op,
&spec,
None,
opts.normalize,
None,
);
let file = dir.join(format!("{file_base}.curl"));
if !opts.force && file.exists() {
skipped += 1;
} else {
std::fs::write(&file, curl)
.map_err(|e| format!("write {}: {e}", file.display()))?;
count += 1;
}
let rel = format!("{folder}/{file_base}.curl");
let step = ChainStep {
request: rel,
extract: hints.clone(),
};
if !hints.is_empty() {
login_by_tag.entry(folder.clone()).or_insert(step.clone());
}
requests_by_tag
.entry(folder.clone())
.or_default()
.push(step);
if opts.edge_cases && has_body_schema(op) {
for (label, edge) in &[("edge-min", EdgeCase::Min), ("edge-max", EdgeCase::Max)]
{
let curl = render_curl(
&base_url,
path,
method,
op,
&spec,
None,
opts.normalize,
Some(*edge),
);
let file = dir.join(format!("{file_base}.{label}.curl"));
if !opts.force && file.exists() {
skipped += 1;
} else {
std::fs::write(&file, curl)
.map_err(|e| format!("write {}: {e}", file.display()))?;
count += 1;
}
}
}
} else {
for named in named {
let safe = sanitize(&named.name);
let curl = render_curl(
&base_url,
path,
method,
op,
&spec,
Some(&named),
opts.normalize,
None,
);
let file = dir.join(format!("{file_base}.{safe}.curl"));
if !opts.force && file.exists() {
skipped += 1;
} else {
std::fs::write(&file, curl)
.map_err(|e| format!("write {}: {e}", file.display()))?;
count += 1;
}
let rel = format!("{folder}/{file_base}.{safe}.curl");
let step = ChainStep {
request: rel,
extract: hints.clone(),
};
if !hints.is_empty() {
login_by_tag.entry(folder.clone()).or_insert(step.clone());
}
requests_by_tag
.entry(folder.clone())
.or_default()
.push(step);
}
}
}
}
emit_chain_templates(&opts.out, &login_by_tag, &requests_by_tag)?;
Ok((count, skipped))
}
#[derive(Clone)]
struct ChainStep {
request: String,
extract: Vec<(String, String)>,
}
fn emit_chain_templates(
out: &std::path::Path,
login_by_tag: &std::collections::BTreeMap<String, ChainStep>,
requests_by_tag: &std::collections::BTreeMap<String, Vec<ChainStep>>,
) -> Result<(), String> {
if login_by_tag.is_empty() {
return Ok(());
}
for (tag, login) in login_by_tag {
let mut steps: Vec<Value> = Vec::new();
steps.push(step_to_json(login));
if let Some(list) = requests_by_tag.get(tag)
&& let Some(other) = list.iter().find(|s| s.request != login.request)
{
let mut step = other.clone();
step.extract.clear();
steps.push(step_to_json(&step));
}
let file = out.join(format!("{tag}-flow.chain.json"));
if file.exists() {
continue;
}
let text = serde_json::to_string_pretty(&Value::Array(steps))
.map_err(|e| format!("serialize chain: {e}"))?;
std::fs::write(&file, text).map_err(|e| format!("write {}: {e}", file.display()))?;
}
Ok(())
}
fn has_body_schema(op: &Value) -> bool {
op.get("requestBody")
.and_then(|rb| rb.get("content"))
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("schema"))
.is_some()
|| op
.get("parameters")
.and_then(Value::as_array)
.map(|arr| {
arr.iter().any(|p| {
p.get("in").and_then(Value::as_str) == Some("body") && p.get("schema").is_some()
})
})
.unwrap_or(false)
}
fn step_to_json(step: &ChainStep) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("request".to_string(), Value::String(step.request.clone()));
if !step.extract.is_empty() {
let mut ex = serde_json::Map::new();
for (var, path) in &step.extract {
ex.insert(var.clone(), Value::String(format!("${path}")));
}
obj.insert("extract".to_string(), Value::Object(ex));
}
Value::Object(obj)
}
struct NamedExample {
name: String,
summary: Option<String>,
body: String,
}
fn collect_named_examples(op: &Value) -> Vec<NamedExample> {
let Some(json) = op
.get("requestBody")
.and_then(|rb| rb.get("content"))
.and_then(|c| c.get("application/json"))
else {
return Vec::new();
};
let Some(examples) = json.get("examples").and_then(Value::as_object) else {
return Vec::new();
};
examples
.iter()
.filter_map(|(key, ex)| {
let value = ex.get("value")?;
let summary = ex
.get("summary")
.and_then(Value::as_str)
.map(str::to_string);
let body = serde_json::to_string(value).ok()?;
Some(NamedExample {
name: key.clone(),
summary,
body,
})
})
.collect()
}
fn is_http_method(m: &str) -> bool {
matches!(
m.to_ascii_lowercase().as_str(),
"get" | "post" | "put" | "patch" | "delete" | "head" | "options"
)
}
pub fn normalize_dynamic_values_public(body: &str) -> String {
normalize_dynamic_values(body)
}
fn normalize_dynamic_values(body: &str) -> String {
use std::sync::OnceLock;
static ISO_RX: OnceLock<regex::Regex> = OnceLock::new();
static UUID_RX: OnceLock<regex::Regex> = OnceLock::new();
let iso = ISO_RX.get_or_init(|| {
regex::Regex::new(
r#""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?""#,
)
.expect("ISO regex")
});
let uuid = UUID_RX.get_or_init(|| {
regex::Regex::new(r#""[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}""#)
.expect("UUID regex")
});
let step1 = iso.replace_all(body, regex::NoExpand(r#""{{$isoTimestamp}}""#));
let step2 = uuid.replace_all(&step1, regex::NoExpand(r#""{{$uuid}}""#));
step2.into_owned()
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) enum EdgeCase {
Min,
Max,
}
#[allow(clippy::too_many_arguments)]
fn render_curl(
base_url: &str,
path: &str,
method: &str,
op: &Value,
spec: &Value,
named: Option<&NamedExample>,
normalize: bool,
edge: Option<EdgeCase>,
) -> String {
let mut url_path = String::new();
let mut buf = String::new();
let mut in_brace = false;
for c in path.chars() {
match c {
'{' => {
in_brace = true;
buf.clear();
}
'}' if in_brace => {
in_brace = false;
let name = if let Some(env) = crate::http::faker::id_env_var(&buf) {
env.to_string()
} else {
buf.clone()
};
url_path.push_str(&format!("{{{{{name}}}}}"));
buf.clear();
}
other if in_brace => buf.push(other),
other => url_path.push(other),
}
}
let (required_query, optional_query, required_headers, optional_headers) =
collect_query_and_header_params(op, spec);
if !required_query.is_empty() {
url_path.push('?');
for (i, (name, value)) in required_query.iter().enumerate() {
if i > 0 {
url_path.push('&');
}
url_path.push_str(name);
url_path.push('=');
url_path.push_str(value);
}
}
let method_upper = method.to_uppercase();
let mut out = String::new();
if let Some(summary) = op.get("summary").and_then(Value::as_str) {
out.push_str(&format!("# {summary}\n"));
}
if let Some(desc) = op.get("description").and_then(Value::as_str) {
for line in desc.lines() {
out.push_str(&format!("# {line}\n"));
}
}
if let Some(n) = named
&& let Some(s) = &n.summary
{
out.push_str(&format!("# example: {s}\n"));
}
out.push_str(&format!("# {method_upper} {path}\n"));
for (var, path_expr) in login_extract_hints(path, &method_upper, op, spec) {
out.push_str(&format!("# extract: {var}=${path_expr}\n"));
}
let body = if let Some(n) = named {
Some(if normalize {
normalize_dynamic_values(&n.body)
} else {
n.body.clone()
})
} else {
op.get("requestBody")
.and_then(|rb| rb.get("content"))
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("example"))
.or_else(|| {
op.get("parameters")
.and_then(Value::as_array)?
.iter()
.find(|p| p.get("in").and_then(Value::as_str) == Some("body"))?
.get("schema")?
.get("example")
})
.and_then(|ex| serde_json::to_string(ex).ok())
.or_else(|| {
let schema = op
.get("requestBody")
.and_then(|rb| rb.get("content"))
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("schema"))
.or_else(|| {
op.get("parameters")
.and_then(Value::as_array)?
.iter()
.find(|p| p.get("in").and_then(Value::as_str) == Some("body"))?
.get("schema")
})?;
let mut visited: HashSet<String> = HashSet::new();
let mut synthesized = synth_example_edge(schema, spec, &mut visited, 0, "", edge);
coherence_pass(&mut synthesized);
serde_json::to_string(&synthesized).ok()
})
.map(|s| {
if normalize {
normalize_dynamic_values(&s)
} else {
s
}
})
};
let mut header_lines: Vec<String> = vec![
" -H 'accept: application/json'".to_string(),
" -H 'Authorization: Bearer {{TOKEN}}'".to_string(),
];
for (name, value) in &required_headers {
header_lines.push(format!(" -H '{name}: {value}'"));
}
if body.is_some() {
header_lines.push(" -H 'content-type: application/json'".to_string());
}
out.push_str(&format!("curl '{base_url}{url_path}' \\\n"));
let needs_explicit_method =
(method_upper != "GET" && body.is_none()) || (method_upper != "POST" && body.is_some());
if needs_explicit_method {
out.push_str(&format!(" -X {method_upper} \\\n"));
}
for (i, line) in header_lines.iter().enumerate() {
if i + 1 < header_lines.len() || body.is_some() {
out.push_str(line);
out.push_str(" \\\n");
} else {
out.push_str(line);
out.push('\n');
}
}
if let Some(b) = body {
out.push_str(&format!(" --data-raw '{}'\n", b.replace('\'', "'\\''")));
}
if !optional_query.is_empty() || !optional_headers.is_empty() {
out.push('\n');
out.push_str("# Optional parameters (uncomment to use):\n");
for (name, value) in &optional_query {
out.push_str(&format!("# ?{name}={value}\n"));
}
for (name, value) in &optional_headers {
out.push_str(&format!("# -H '{name}: {value}'\n"));
}
}
out
}
fn login_extract_hints(
path: &str,
method: &str,
op: &Value,
spec: &Value,
) -> Vec<(String, String)> {
if method != "POST" {
return Vec::new();
}
let last = path
.rsplit('/')
.find(|s| !s.is_empty())
.unwrap_or("")
.to_ascii_lowercase();
let is_login = matches!(
last.as_str(),
"login" | "signin" | "sign-in" | "sign_in" | "token" | "authenticate" | "sessions"
);
if !is_login {
return Vec::new();
}
let mut hints: Vec<(String, String)> = Vec::new();
let schema = op
.get("responses")
.and_then(Value::as_object)
.and_then(|responses| {
responses
.get("200")
.or_else(|| responses.get("201"))
.or_else(|| responses.get("default"))
})
.and_then(|r| r.get("content"))
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("schema"));
let props: Vec<String> = if let Some(schema) = schema {
let resolved = if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
resolve_ref(spec, r).unwrap_or(schema)
} else {
schema
};
resolved
.get("properties")
.and_then(Value::as_object)
.map(|p| p.keys().cloned().collect())
.unwrap_or_default()
} else {
Vec::new()
};
let mut push_if_matches = |candidates: &[&str], var: &str| {
for name in &props {
let key = name.to_ascii_lowercase().replace(['_', '-'], "");
if candidates.iter().any(|c| c == &key.as_str()) {
hints.push((var.to_string(), format!(".{name}")));
return;
}
}
};
push_if_matches(&["accesstoken", "token"], "TOKEN");
push_if_matches(&["refreshtoken"], "REFRESH_TOKEN");
push_if_matches(&["idtoken"], "ID_TOKEN");
if hints.is_empty() {
hints.push(("TOKEN".to_string(), ".access_token".to_string()));
}
hints
}
#[allow(clippy::type_complexity)]
fn collect_query_and_header_params(
op: &Value,
spec: &Value,
) -> (
Vec<(String, String)>,
Vec<(String, String)>,
Vec<(String, String)>,
Vec<(String, String)>,
) {
let mut req_q = Vec::new();
let mut opt_q = Vec::new();
let mut req_h = Vec::new();
let mut opt_h = Vec::new();
let params = op.get("parameters").and_then(Value::as_array);
let Some(params) = params else {
return (req_q, opt_q, req_h, opt_h);
};
for p in params {
let resolved = if let Some(r) = p.get("$ref").and_then(Value::as_str) {
match resolve_ref(spec, r) {
Some(v) => v,
None => continue,
}
} else {
p
};
let name = match resolved.get("name").and_then(Value::as_str) {
Some(n) => n.to_string(),
None => continue,
};
let loc = resolved
.get("in")
.and_then(Value::as_str)
.unwrap_or_default();
if !matches!(loc, "query" | "header") {
continue;
}
let required = resolved
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false);
let value = param_placeholder_value(resolved, &name);
let bucket = match (loc, required) {
("query", true) => &mut req_q,
("query", false) => &mut opt_q,
("header", true) => &mut req_h,
("header", false) => &mut opt_h,
_ => unreachable!(),
};
bucket.push((name, value));
}
(req_q, opt_q, req_h, opt_h)
}
fn param_placeholder_value(param: &Value, name: &str) -> String {
let schema_or_self = param.get("schema").unwrap_or(param);
if let Some(ex) = schema_or_self.get("example") {
return json_to_string_flat(ex);
}
if let Some(default) = schema_or_self.get("default") {
return json_to_string_flat(default);
}
if let Some(en) = schema_or_self.get("enum").and_then(Value::as_array)
&& let Some(first) = en.first()
{
return json_to_string_flat(first);
}
format!("{{{{{name}}}}}")
}
fn json_to_string_flat(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
_ => v.to_string(),
}
}
fn synth_example_edge(
schema: &Value,
spec: &Value,
visited: &mut HashSet<String>,
depth: u32,
prop: &str,
edge: Option<EdgeCase>,
) -> Value {
match edge {
None => synth_example_hinted(schema, spec, visited, depth, prop),
Some(e) => synth_example_edge_inner(schema, spec, visited, depth, prop, e),
}
}
fn synth_example_edge_inner(
schema: &Value,
spec: &Value,
visited: &mut HashSet<String>,
depth: u32,
prop: &str,
edge: EdgeCase,
) -> Value {
if depth > 5 {
return Value::Null;
}
if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
if visited.contains(r) {
return Value::Null;
}
visited.insert(r.to_string());
if let Some(resolved) = resolve_ref(spec, r) {
return synth_example_edge_inner(resolved, spec, visited, depth + 1, prop, edge);
}
return Value::Null;
}
if let Some(example) = schema.get("example") {
return example.clone();
}
let ty = schema.get("type").and_then(Value::as_str).unwrap_or("");
match ty {
"object" => {
let mut obj = serde_json::Map::new();
if let Some(props) = schema.get("properties").and_then(Value::as_object) {
for (k, v) in props {
obj.insert(
k.clone(),
synth_example_edge_inner(v, spec, visited, depth + 1, k, edge),
);
}
}
Value::Object(obj)
}
"array" => {
let item = schema
.get("items")
.map(|i| synth_example_edge_inner(i, spec, visited, depth + 1, "", edge))
.unwrap_or(Value::Null);
if edge == EdgeCase::Max {
Value::Array(vec![item.clone(), item.clone(), item])
} else {
Value::Array(vec![item])
}
}
"string" => {
if let Some(en) = schema.get("enum").and_then(Value::as_array)
&& en.len() >= 2
{
return match edge {
EdgeCase::Min => en.last().cloned().unwrap_or(Value::Null),
EdgeCase::Max => en.first().cloned().unwrap_or(Value::Null),
};
}
if let Some(fmt) = schema.get("format").and_then(Value::as_str)
&& matches!(fmt, "date-time" | "date" | "email" | "uuid")
{
return Value::String(match fmt {
"date-time" => "2026-01-01T00:00:00Z".to_string(),
"date" => "2026-01-01".to_string(),
"email" => "user@example.com".to_string(),
"uuid" => "00000000-0000-0000-0000-000000000000".to_string(),
_ => unreachable!(),
});
}
let min_len = schema.get("minLength").and_then(Value::as_u64).unwrap_or(1) as usize;
let max_len = schema
.get("maxLength")
.and_then(Value::as_u64)
.unwrap_or(64) as usize;
let base = if !prop.is_empty()
&& let Some(Value::String(s)) = crate::http::faker::placeholder_for(prop, ty)
{
s
} else {
"string".to_string()
};
let n = match edge {
EdgeCase::Min => min_len.max(1),
EdgeCase::Max => max_len.min(64),
};
if n < base.chars().count() {
Value::String(base.chars().take(n).collect())
} else if n == base.chars().count() {
Value::String(base)
} else {
let pad = "x".repeat(n - base.chars().count());
Value::String(format!("{base}{pad}"))
}
}
"integer" => {
let mut v = match edge {
EdgeCase::Min => schema.get("minimum").and_then(Value::as_i64).unwrap_or(0),
EdgeCase::Max => schema
.get("maximum")
.and_then(Value::as_i64)
.unwrap_or(9999),
};
if let Some(excl) = schema.get("exclusiveMinimum").and_then(Value::as_i64)
&& edge == EdgeCase::Min
{
v = excl + 1;
}
if let Some(excl) = schema.get("exclusiveMaximum").and_then(Value::as_i64)
&& edge == EdgeCase::Max
{
v = excl - 1;
}
Value::Number(v.into())
}
"number" => {
let v = match edge {
EdgeCase::Min => schema.get("minimum").and_then(Value::as_f64).unwrap_or(0.0),
EdgeCase::Max => schema
.get("maximum")
.and_then(Value::as_f64)
.unwrap_or(9999.99),
};
Value::Number(serde_json::Number::from_f64(v).unwrap_or(0.into()))
}
"boolean" => match edge {
EdgeCase::Min => Value::Bool(false),
EdgeCase::Max => Value::Bool(true),
},
_ => {
for k in &["allOf", "oneOf", "anyOf"] {
if let Some(arr) = schema.get(*k).and_then(Value::as_array) {
let pick = if *k == "allOf" {
arr.first()
} else if arr.len() >= 2 {
arr.last()
} else {
arr.first()
};
if let Some(chosen) = pick {
return synth_example_edge_inner(
chosen,
spec,
visited,
depth + 1,
prop,
edge,
);
}
}
}
Value::Null
}
}
}
fn synth_example_hinted(
schema: &Value,
spec: &Value,
visited: &mut HashSet<String>,
depth: u32,
prop: &str,
) -> Value {
if depth > 5 {
return Value::Null;
}
if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
if visited.contains(r) {
return Value::Null;
}
visited.insert(r.to_string());
if let Some(resolved) = resolve_ref(spec, r) {
return synth_example_hinted(resolved, spec, visited, depth + 1, prop);
}
return Value::Null;
}
if let Some(example) = schema.get("example") {
return example.clone();
}
if let Some(default) = schema.get("default") {
return default.clone();
}
let ty = schema.get("type").and_then(Value::as_str).unwrap_or("");
if !prop.is_empty()
&& let Some(v) = crate::http::faker::placeholder_for(prop, ty)
{
return v;
}
match ty {
"object" => {
let mut obj = serde_json::Map::new();
if let Some(props) = schema.get("properties").and_then(Value::as_object) {
for (k, v) in props {
obj.insert(
k.clone(),
synth_example_hinted(v, spec, visited, depth + 1, k),
);
}
}
Value::Object(obj)
}
"array" => {
let item = schema
.get("items")
.map(|i| synth_example_hinted(i, spec, visited, depth + 1, ""))
.unwrap_or(Value::Null);
Value::Array(vec![item])
}
"string" => {
if let Some(fmt) = schema.get("format").and_then(Value::as_str) {
Value::String(match fmt {
"date-time" => "2026-01-01T00:00:00Z".to_string(),
"date" => "2026-01-01".to_string(),
"email" => "user@example.com".to_string(),
"uuid" => "00000000-0000-0000-0000-000000000000".to_string(),
_ => "string".to_string(),
})
} else if let Some(en) = schema.get("enum").and_then(Value::as_array) {
en.first()
.cloned()
.unwrap_or_else(|| Value::String("string".into()))
} else {
Value::String("string".to_string())
}
}
"integer" => Value::Number(0.into()),
"number" => Value::Number(serde_json::Number::from_f64(0.0).unwrap()),
"boolean" => Value::Bool(false),
_ => {
for k in &["allOf", "oneOf", "anyOf"] {
if let Some(arr) = schema.get(*k).and_then(Value::as_array)
&& let Some(first) = arr.first()
{
return synth_example_hinted(first, spec, visited, depth + 1, prop);
}
}
Value::Null
}
}
}
pub(crate) fn coherence_pass(v: &mut Value) {
match v {
Value::Object(obj) => {
for (_, child) in obj.iter_mut() {
coherence_pass(child);
}
let keys: Vec<String> = obj.keys().cloned().collect();
let mut lc_str: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut lc_num: std::collections::HashMap<String, f64> =
std::collections::HashMap::new();
let mut lc_present: std::collections::HashSet<String> =
std::collections::HashSet::new();
for k in &keys {
let lk = crate::http::faker::normalize_key(k);
lc_present.insert(lk.clone());
if let Some(s) = obj.get(k).and_then(Value::as_str) {
lc_str.insert(lk.clone(), s.to_string());
}
if let Some(n) = obj.get(k).and_then(Value::as_f64) {
lc_num.insert(lk, n);
}
}
let first = lc_str
.get("firstname")
.or_else(|| lc_str.get("givenname"))
.or_else(|| lc_str.get("fname"))
.cloned();
let last = lc_str
.get("lastname")
.or_else(|| lc_str.get("familyname"))
.or_else(|| lc_str.get("surname"))
.or_else(|| lc_str.get("lname"))
.cloned();
if let (Some(f), Some(l)) = (first, last) {
let derived_email =
format!("{}.{}@example.com", f.to_lowercase(), l.to_lowercase());
let derived_full = format!("{f} {l}");
let derived_user = format!(
"{}{}",
f.chars().next().unwrap_or('j').to_ascii_lowercase(),
l.to_lowercase()
);
for k in &keys {
let lk = crate::http::faker::normalize_key(k);
match lk.as_str() {
"email" | "emailaddress" | "emailid"
if obj.get(k).and_then(Value::as_str) == Some("user@example.com") =>
{
obj.insert(k.clone(), Value::String(derived_email.clone()));
}
"fullname" | "name" | "displayname"
if obj.get(k).and_then(Value::as_str) == Some("John Smith") =>
{
obj.insert(k.clone(), Value::String(derived_full.clone()));
}
"username" if obj.get(k).and_then(Value::as_str) == Some("jsmith") => {
obj.insert(k.clone(), Value::String(derived_user.clone()));
}
_ => {}
}
}
}
for (created_key, updated_key) in &[
("createdat", "updatedat"),
("createdat", "modifiedat"),
("insertedat", "updatedat"),
("starttime", "endtime"),
("startsat", "endsat"),
] {
let created = lc_str.get(*created_key).cloned();
let updated_exists = lc_present.contains(*updated_key);
if let (Some(created), true) = (created, updated_exists)
&& let Some(bumped) = bump_iso_by_minutes(&created, 30)
{
for k in &keys {
if crate::http::faker::normalize_key(k) == *updated_key
&& obj.get(k).and_then(Value::as_str) == Some(created.as_str())
{
obj.insert(k.clone(), Value::String(bumped.clone()));
}
}
}
}
let amount = lc_num
.get("amount")
.or_else(|| lc_num.get("price"))
.copied();
let quantity = lc_num
.get("quantity")
.or_else(|| lc_num.get("qty"))
.copied();
let subtotal = lc_num.get("subtotal").copied();
let tax = lc_num.get("tax").copied();
let derived_total = if let (Some(s), Some(t)) = (subtotal, tax) {
Some(round_money(s + t))
} else if let (Some(a), Some(q)) = (amount, quantity) {
Some(round_money(a * q))
} else {
None
};
if let Some(total) = derived_total {
for k in &keys {
if crate::http::faker::normalize_key(k) == "total"
&& obj.get(k).and_then(Value::as_f64) == Some(9.99)
&& let Some(n) = serde_json::Number::from_f64(total)
{
obj.insert(k.clone(), Value::Number(n));
}
}
}
}
Value::Array(arr) => {
for item in arr {
coherence_pass(item);
}
}
_ => {}
}
}
fn round_money(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
fn bump_iso_by_minutes(input: &str, minutes: i64) -> Option<String> {
let (year, month, day, hour, minute, sec, rest) = parse_iso(input)?;
let total_min = hour as i64 * 60 + minute as i64 + minutes;
let day_offset = total_min.div_euclid(24 * 60);
let mod_min = total_min.rem_euclid(24 * 60) as u32;
let new_hour = mod_min / 60;
let new_min = mod_min % 60;
let (nyear, nmonth, nday) =
civil_from_days(days_from_civil(year, month, day) + day_offset as i32);
let base = format!("{nyear:04}-{nmonth:02}-{nday:02}T{new_hour:02}:{new_min:02}:{sec:02}");
Some(format!("{base}{rest}"))
}
#[allow(clippy::type_complexity)]
fn parse_iso(input: &str) -> Option<(i32, u32, u32, u32, u32, u32, String)> {
let bytes = input.as_bytes();
if bytes.len() < 19 {
return None;
}
let get = |s: usize, e: usize| -> Option<&str> { input.get(s..e) };
let year: i32 = get(0, 4)?.parse().ok()?;
if bytes[4] != b'-' {
return None;
}
let month: u32 = get(5, 7)?.parse().ok()?;
if bytes[7] != b'-' {
return None;
}
let day: u32 = get(8, 10)?.parse().ok()?;
if bytes[10] != b'T' {
return None;
}
let hour: u32 = get(11, 13)?.parse().ok()?;
if bytes[13] != b':' {
return None;
}
let minute: u32 = get(14, 16)?.parse().ok()?;
if bytes[16] != b':' {
return None;
}
let sec: u32 = get(17, 19)?.parse().ok()?;
let rest = input.get(19..).unwrap_or("").to_string();
Some((year, month, day, hour, minute, sec, rest))
}
fn civil_from_days(z: i32) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u32;
let doy = (153 * if m > 2 { m - 3 } else { m + 9 } + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i32 - 719_468
}
fn resolve_ref<'a>(spec: &'a Value, r: &str) -> Option<&'a Value> {
let r = r.strip_prefix("#/")?;
let mut cur = spec;
for part in r.split('/') {
cur = cur.get(part)?;
}
Some(cur)
}
fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else {
out.push('-');
}
}
let collapsed: String = out
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
"op".to_string()
} else {
collapsed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_one_curl_per_operation() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("api.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com/v1" }],
"paths": {
"/users/{id}": {
"get": { "tags": ["users"], "operationId": "getUser", "summary": "Get a user" },
"delete": { "tags": ["users"], "operationId": "deleteUser", "security": [{ "bearer": [] }] }
},
"/users": {
"post": {
"tags": ["users"],
"operationId": "createUser",
"requestBody": { "content": { "application/json": { "example": { "name": "Alice" } } } }
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("out");
let n = run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
assert_eq!(n.0, 3);
let get = std::fs::read_to_string(out.join("users/getUser.curl")).unwrap();
assert!(get.contains("curl 'https://api.example.com/v1/users/{{id}}'"));
assert!(get.contains("# Get a user"));
let del = std::fs::read_to_string(out.join("users/deleteUser.curl")).unwrap();
assert!(del.contains("-X DELETE"));
assert!(del.contains("Authorization: Bearer {{TOKEN}}"));
let post = std::fs::read_to_string(out.join("users/createUser.curl")).unwrap();
assert!(
!post.contains("-X POST"),
"POST+body shouldn't need -X: {post}"
);
assert!(post.contains(r#"--data-raw '{"name":"Alice"}'"#));
}
#[test]
fn named_examples_map_expands_to_one_file_per_example() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("events.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/admin/event": {
"post": {
"operationId": "TriggerEvent",
"tags": ["Admin"],
"requestBody": {
"content": {
"application/json": {
"examples": {
"OrderCreated": {
"summary": "Order Created",
"value": { "eventName": "OrderCreated", "data": { "id": 1 } }
},
"OrderCancelled": {
"value": { "eventName": "OrderCancelled", "data": { "id": 2 } }
}
}
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("out");
let n = run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
assert_eq!(n.0, 2, "should emit one stub per named example");
let created =
std::fs::read_to_string(out.join("Admin/TriggerEvent.OrderCreated.curl")).unwrap();
assert!(
created.contains(r#"--data-raw '{"eventName":"OrderCreated","data":{"id":1}}'"#),
"OrderCreated body missing: {created}"
);
assert!(
created.contains("# example: Order Created"),
"example summary comment missing: {created}"
);
assert!(std::fs::exists(out.join("Admin/TriggerEvent.OrderCancelled.curl")).unwrap());
}
#[test]
fn schema_synthesis_fills_body_when_no_example_provided() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"post": {
"operationId": "CreateThing",
"tags": ["things"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"count": { "type": "integer" },
"createdAt": { "type": "string", "format": "date-time" },
"id": { "type": "string", "format": "uuid" }
}
}
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("things/CreateThing.curl")).unwrap();
assert!(
body.contains(r#""name":"John Smith""#),
"synthesized name: {body}"
);
assert!(body.contains(r#""count":1"#), "synthesized count: {body}");
assert!(
body.contains(r#""createdAt":"2026-01-01T00:00:00Z""#),
"date-time format: {body}"
);
assert!(
body.contains(r#""id":"00000000-0000-0000-0000-000000000000""#),
"uuid format: {body}"
);
}
#[test]
fn schema_synthesis_resolves_local_refs_and_survives_cycles() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r##"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"components": {
"schemas": {
"Node": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"child": { "$ref": "#/components/schemas/Node" }
}
}
}
},
"paths": {
"/nodes": {
"post": {
"operationId": "PostNode",
"tags": ["nodes"],
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Node" }
}
}
}
}
}
}
}"##,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("nodes/PostNode.curl")).unwrap();
assert!(body.contains(r#""id":0"#), "cycle-broken body: {body}");
}
#[test]
fn normalize_replaces_iso_timestamps_and_uuids() {
let raw = r#"{"orderId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","asOfDate":"2026-07-09T17:35:39.4944815Z","label":"OrderCreated"}"#;
let out = normalize_dynamic_values(raw);
assert!(
out.contains(r#""orderId":"{{$uuid}}""#),
"uuid not replaced: {out}"
);
assert!(
out.contains(r#""asOfDate":"{{$isoTimestamp}}""#),
"iso timestamp not replaced: {out}"
);
assert!(out.contains(r#""label":"OrderCreated""#));
}
#[test]
fn normalize_ignores_uppercase_uuid_and_date_only() {
let raw = r#"{"const":"ABCDEF12-3456-7890-ABCD-EF1234567890","birthDate":"2026-07-09","note":"1234-5678"}"#;
let out = normalize_dynamic_values(raw);
assert!(out.contains("ABCDEF12-3456-7890-ABCD-EF1234567890"));
assert!(out.contains(r#""birthDate":"2026-07-09""#));
assert!(out.contains(r#""note":"1234-5678""#));
}
#[test]
fn discover_normalize_flag_wires_through_to_generated_body() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/e": {
"post": {
"operationId": "Ping",
"tags": ["p"],
"requestBody": {
"content": {
"application/json": {
"example": {
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"at": "2026-07-09T17:35:39.4944815Z"
}
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: true,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("p/Ping.curl")).unwrap();
assert!(body.contains(r#""id":"{{$uuid}}""#), "body: {body}");
assert!(body.contains(r#""at":"{{$isoTimestamp}}""#), "body: {body}");
}
#[test]
fn edge_cases_flag_emits_min_and_max_variants_per_body_operation() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"post": {
"operationId": "createThing",
"tags": ["things"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 3, "maxLength": 8 },
"status": { "type": "string", "enum": ["draft","published","archived"] },
"score": { "type": "integer", "minimum": 1, "maximum": 100 }
}
}
}
}
}
}
},
"/things/{id}": {
"get": { "operationId": "getThing", "tags": ["things"] }
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
let n = run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: true,
force: true,
})
.unwrap();
assert_eq!(n.0, 4);
let happy = std::fs::read_to_string(out.join("things/createThing.curl")).unwrap();
let emin = std::fs::read_to_string(out.join("things/createThing.edge-min.curl")).unwrap();
let emax = std::fs::read_to_string(out.join("things/createThing.edge-max.curl")).unwrap();
assert!(
happy.contains(r#""status":"active""#),
"happy status: {happy}"
);
assert!(
emin.contains(r#""status":"archived""#),
"min status: {emin}"
);
assert!(emin.contains(r#""score":1"#), "min score: {emin}");
assert!(emax.contains(r#""status":"draft""#), "max status: {emax}");
assert!(emax.contains(r#""score":100"#), "max score: {emax}");
assert!(!out.join("things/getThing.edge-min.curl").exists());
}
#[test]
fn edge_cases_preserves_format_typed_strings_intact() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"post": {
"operationId": "createThing",
"tags": ["things"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"createdAt": { "type": "string", "format": "date-time" },
"email": { "type": "string", "format": "email" },
"id": { "type": "string", "format": "uuid" }
}
}
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: true,
force: true,
})
.unwrap();
let emin = std::fs::read_to_string(out.join("things/createThing.edge-min.curl")).unwrap();
let emax = std::fs::read_to_string(out.join("things/createThing.edge-max.curl")).unwrap();
for body in &[emin, emax] {
assert!(
body.contains(r#""createdAt":"2026-01-01T00:00:00Z""#),
"createdAt intact: {body}"
);
assert!(
body.contains(r#""email":"user@example.com""#),
"email intact: {body}"
);
assert!(
body.contains(r#""id":"00000000-0000-0000-0000-000000000000""#),
"uuid intact: {body}"
);
}
}
#[test]
fn edge_cases_flag_off_by_default_produces_only_happy_path() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"post": {
"operationId": "createThing",
"tags": ["things"],
"requestBody": {
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
assert!(out.join("things/createThing.curl").exists());
assert!(!out.join("things/createThing.edge-min.curl").exists());
assert!(!out.join("things/createThing.edge-max.curl").exists());
}
#[test]
fn coherence_pass_derives_email_from_first_last() {
let mut v = serde_json::json!({
"firstName": "John",
"lastName": "Smith",
"emailAddress": "user@example.com",
"fullName": "John Smith",
"username": "jsmith",
});
coherence_pass(&mut v);
assert_eq!(v["emailAddress"], "john.smith@example.com");
assert_eq!(v["fullName"], "John Smith");
assert_eq!(v["username"], "jsmith");
}
#[test]
fn coherence_pass_bumps_updated_after_created() {
let mut v = serde_json::json!({
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
});
coherence_pass(&mut v);
assert_eq!(v["updatedAt"], "2026-01-01T00:30:00Z");
}
#[test]
fn coherence_pass_computes_total_from_amount_and_quantity() {
let mut v = serde_json::json!({
"amount": 9.99,
"quantity": 3,
"total": 9.99,
});
coherence_pass(&mut v);
assert_eq!(v["total"].as_f64().unwrap(), 29.97);
}
#[test]
fn coherence_pass_computes_total_from_subtotal_plus_tax() {
let mut v = serde_json::json!({
"subtotal": 20.0,
"tax": 1.6,
"total": 9.99,
});
coherence_pass(&mut v);
assert_eq!(v["total"].as_f64().unwrap(), 21.6);
}
#[test]
fn coherence_pass_recurses_into_nested_objects_and_arrays() {
let mut v = serde_json::json!({
"user": { "firstName": "John", "lastName": "Smith", "email": "user@example.com" },
"items": [
{ "amount": 5.0, "quantity": 2, "total": 9.99 }
]
});
coherence_pass(&mut v);
assert_eq!(v["user"]["email"], "john.smith@example.com");
assert_eq!(v["items"][0]["total"].as_f64().unwrap(), 10.0);
}
#[test]
fn login_endpoints_emit_extract_hints_and_chain_template() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/auth/login": {
"post": {
"operationId": "login",
"tags": ["auth"],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"access_token": { "type": "string" },
"refresh_token": { "type": "string" }
}
}
}
}
}
}
}
},
"/auth/me": {
"get": { "operationId": "getMe", "tags": ["auth"] }
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let login_body = std::fs::read_to_string(out.join("auth/login.curl")).unwrap();
assert!(
login_body.contains("# extract: TOKEN=$.access_token"),
"extract TOKEN hint missing: {login_body}"
);
assert!(
login_body.contains("# extract: REFRESH_TOKEN=$.refresh_token"),
"extract REFRESH_TOKEN hint missing: {login_body}"
);
let me_body = std::fs::read_to_string(out.join("auth/getMe.curl")).unwrap();
assert!(
!me_body.contains("# extract:"),
"non-login should have no extract hint: {me_body}"
);
let chain_path = out.join("auth-flow.chain.json");
let chain_text = std::fs::read_to_string(&chain_path).unwrap();
let chain: serde_json::Value = serde_json::from_str(&chain_text).unwrap();
let steps = chain.as_array().unwrap();
assert_eq!(steps.len(), 2, "chain has 2 steps: {chain_text}");
assert_eq!(steps[0]["request"].as_str().unwrap(), "auth/login.curl");
assert_eq!(
steps[0]["extract"]["TOKEN"].as_str().unwrap(),
"$.access_token"
);
assert_eq!(steps[1]["request"].as_str().unwrap(), "auth/getMe.curl");
}
#[test]
fn login_extract_hint_falls_back_when_schema_absent() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/login": {
"post": { "operationId": "signIn", "tags": ["auth"] }
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("auth/signIn.curl")).unwrap();
assert!(
body.contains("# extract: TOKEN=$.access_token"),
"fallback missing: {body}"
);
}
#[test]
fn no_chain_template_when_no_login_endpoint() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"get": { "operationId": "listThings", "tags": ["things"] }
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let has_chain = std::fs::read_dir(&out)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().ends_with(".chain.json"));
assert!(!has_chain, "no chain template expected");
}
#[test]
fn path_params_upgrade_to_env_vars_when_named_as_wellknown_ids() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/merchants/{merchantId}/locations/{locationId}/thing/{thingId}": {
"get": {
"operationId": "getThing",
"tags": ["things"]
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("things/getThing.curl")).unwrap();
assert!(
body.contains("/merchants/{{MERCHANT_ID}}/"),
"MERCHANT_ID: {body}"
);
assert!(
body.contains("/locations/{{LOCATION_ID}}/"),
"LOCATION_ID: {body}"
);
assert!(body.contains("/thing/{{thingId}}"), "unknown kept: {body}");
}
#[test]
fn faker_vocab_fills_realistic_placeholders_by_property_name() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/customers": {
"post": {
"operationId": "createCustomer",
"tags": ["customers"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"emailAddress": { "type": "string" },
"merchantId": { "type": "integer" },
"status": { "type": "string" },
"quantity": { "type": "integer" }
}
}
}
}
}
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("customers/createCustomer.curl")).unwrap();
assert!(body.contains(r#""firstName":"John""#), "firstName: {body}");
assert!(body.contains(r#""lastName":"Smith""#), "lastName: {body}");
assert!(
body.contains(r#""emailAddress":"john.smith@example.com""#),
"email: {body}"
);
assert!(
body.contains(r#""merchantId":"{{MERCHANT_ID}}""#),
"merchantId env-var: {body}"
);
assert!(body.contains(r#""status":"active""#), "status: {body}");
assert!(body.contains(r#""quantity":1"#), "quantity: {body}");
}
#[test]
fn required_query_params_become_url_query_string() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"get": {
"operationId": "listThings",
"tags": ["things"],
"parameters": [
{ "name": "merchantId", "in": "query", "required": true,
"schema": { "type": "integer" } },
{ "name": "status", "in": "query", "required": true,
"schema": { "type": "string", "enum": ["active","inactive"] } }
]
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("things/listThings.curl")).unwrap();
assert!(
body.contains("things?merchantId={{merchantId}}&status=active"),
"url query missing: {body}"
);
}
#[test]
fn required_header_params_become_dash_h_lines() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"get": {
"operationId": "listThings",
"tags": ["things"],
"parameters": [
{ "name": "X-Merchant-Id", "in": "header", "required": true,
"schema": { "type": "string" } }
]
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("things/listThings.curl")).unwrap();
assert!(
body.contains("-H 'X-Merchant-Id: {{X-Merchant-Id}}'"),
"header line missing: {body}"
);
}
#[test]
fn optional_params_surface_as_commented_hints() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{
"openapi": "3.0.0",
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/things": {
"get": {
"operationId": "listThings",
"tags": ["things"],
"parameters": [
{ "name": "cursor", "in": "query", "required": false,
"schema": { "type": "string" } },
{ "name": "X-Debug", "in": "header", "required": false,
"schema": { "type": "boolean", "default": false } }
]
}
}
}
}"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let body = std::fs::read_to_string(out.join("things/listThings.curl")).unwrap();
assert!(
body.contains("# Optional parameters (uncomment to use):"),
"hint header missing: {body}"
);
assert!(
body.contains("# ?cursor={{cursor}}"),
"cursor hint: {body}"
);
assert!(
body.contains("# -H 'X-Debug: false'"),
"X-Debug hint (with default): {body}"
);
}
#[test]
fn swagger2_host_basepath_and_untagged() {
let dir = tempfile::tempdir().unwrap();
let spec = dir.path().join("s.json");
std::fs::write(
&spec,
r#"{ "swagger": "2.0", "host": "x.test", "basePath": "/api", "schemes": ["https"],
"paths": { "/ping": { "get": {} } } }"#,
)
.unwrap();
let out = dir.path().join("o");
run(&Options {
spec: spec.to_string_lossy().into_owned(),
out: out.clone(),
base_url: None,
normalize: false,
edge_cases: false,
force: true,
})
.unwrap();
let f = std::fs::read_to_string(out.join("untagged/get-ping.curl")).unwrap();
assert!(f.contains("curl 'https://x.test/api/ping'"), "{f}");
}
}