use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde_json::Value;
const HTTP_METHODS: &[&str] = &[
"get", "put", "post", "delete", "options", "head", "patch", "trace",
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum SecuritySpec {
Absent,
Empty,
Schemes(BTreeSet<String>),
}
fn workspace_root() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.expect("workspace root resolves from CARGO_MANIFEST_DIR")
}
fn yaml_path() -> PathBuf {
workspace_root().join("openapi/tensor-wasm-api.yaml")
}
fn json_path() -> PathBuf {
workspace_root().join("crates/tensor-wasm-api/openapi.json")
}
fn parse_yaml_path_keys(yaml: &str) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
let mut in_paths = false;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_paths = line == "paths:";
continue;
}
if !in_paths {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') && rest.starts_with('/') && rest.ends_with(':') {
let key = rest.trim_end_matches(':').trim().to_string();
paths.insert(key);
}
}
}
paths
}
fn strip_trailing_comment(line: &str) -> &str {
if line.contains('"') || line.contains('\'') {
return line.trim_end();
}
match line.find('#') {
Some(idx) => line[..idx].trim_end(),
None => line.trim_end(),
}
}
fn parse_yaml_operation_security(yaml: &str) -> BTreeMap<String, SecuritySpec> {
let mut out: BTreeMap<String, SecuritySpec> = BTreeMap::new();
let mut in_paths = false;
let mut cur_path: Option<String> = None;
let mut cur_method: Option<String> = None;
let mut in_security_block = false;
let mut security_schemes: BTreeSet<String> = BTreeSet::new();
let flush_block = |out: &mut BTreeMap<String, SecuritySpec>,
path: &Option<String>,
method: &Option<String>,
schemes: &mut BTreeSet<String>| {
if let (Some(p), Some(m)) = (path, method) {
let key = format!("{m} {p}");
let spec = if schemes.is_empty() {
SecuritySpec::Empty
} else {
SecuritySpec::Schemes(std::mem::take(schemes))
};
out.insert(key, spec);
}
schemes.clear();
};
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
if indent == 0 {
if in_security_block {
flush_block(&mut out, &cur_path, &cur_method, &mut security_schemes);
in_security_block = false;
}
in_paths = line == "paths:";
cur_path = None;
cur_method = None;
continue;
}
if !in_paths {
continue;
}
if in_security_block {
let trimmed = line.trim_start();
if indent >= 8 && trimmed.starts_with('-') {
let item = trimmed[1..].trim();
let name = item.split(':').next().unwrap_or("").trim();
if !name.is_empty() {
security_schemes.insert(name.to_string());
}
continue;
}
flush_block(&mut out, &cur_path, &cur_method, &mut security_schemes);
in_security_block = false;
}
if indent == 2 {
let rest = line.trim();
if rest.starts_with('/') && rest.ends_with(':') {
cur_path = Some(rest.trim_end_matches(':').trim().to_string());
cur_method = None;
}
continue;
}
if indent == 4 {
let rest = line.trim();
let name = rest.trim_end_matches(':').trim().to_lowercase();
if rest.ends_with(':') && HTTP_METHODS.contains(&name.as_str()) {
cur_method = Some(name);
if let (Some(p), Some(m)) = (&cur_path, &cur_method) {
out.entry(format!("{m} {p}"))
.or_insert(SecuritySpec::Absent);
}
} else {
cur_method = None;
}
continue;
}
if indent == 6 {
let rest = line.trim();
if let Some(after) = rest.strip_prefix("security:") {
let val = after.trim();
let key = match (&cur_path, &cur_method) {
(Some(p), Some(m)) => format!("{m} {p}"),
_ => continue,
};
if val == "[]" {
out.insert(key, SecuritySpec::Empty);
} else if val.is_empty() {
in_security_block = true;
security_schemes.clear();
} else {
let mut set = BTreeSet::new();
for tok in val
.trim_matches(|c| c == '[' || c == ']' || c == '{' || c == '}')
.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
{
let t = tok.trim();
if !t.is_empty() {
set.insert(t.to_string());
}
}
out.insert(
key,
if set.is_empty() {
SecuritySpec::Empty
} else {
SecuritySpec::Schemes(set)
},
);
}
}
continue;
}
}
if in_security_block {
flush_block(&mut out, &cur_path, &cur_method, &mut security_schemes);
}
out
}
fn json_operation_security(op: &Value) -> SecuritySpec {
match op.get("security") {
None => SecuritySpec::Absent,
Some(Value::Array(arr)) if arr.is_empty() => SecuritySpec::Empty,
Some(Value::Array(arr)) => {
let mut set = BTreeSet::new();
for req in arr {
if let Some(obj) = req.as_object() {
for name in obj.keys() {
set.insert(name.clone());
}
}
}
if set.is_empty() {
SecuritySpec::Empty
} else {
SecuritySpec::Schemes(set)
}
}
Some(_) => {
SecuritySpec::Schemes(["<non-array security>".to_string()].into_iter().collect())
}
}
}
fn json_operation_security_map(json: &Value) -> BTreeMap<String, SecuritySpec> {
let mut out = BTreeMap::new();
let Some(paths) = json.get("paths").and_then(Value::as_object) else {
return out;
};
for (path, item) in paths {
let Some(item_obj) = item.as_object() else {
continue;
};
for (method, op) in item_obj {
let m = method.to_lowercase();
if !HTTP_METHODS.contains(&m.as_str()) {
continue;
}
out.insert(format!("{m} {path}"), json_operation_security(op));
}
}
out
}
fn parse_yaml_operation_ids(yaml: &str) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
let mut in_paths = false;
let mut cur_path: Option<String> = None;
let mut cur_method: Option<String> = None;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
if indent == 0 {
in_paths = line == "paths:";
cur_path = None;
cur_method = None;
continue;
}
if !in_paths {
continue;
}
if indent == 2 {
let rest = line.trim();
if rest.starts_with('/') && rest.ends_with(':') {
cur_path = Some(rest.trim_end_matches(':').trim().to_string());
cur_method = None;
}
continue;
}
if indent == 4 {
let rest = line.trim();
let name = rest.trim_end_matches(':').trim().to_lowercase();
if rest.ends_with(':') && HTTP_METHODS.contains(&name.as_str()) {
cur_method = Some(name);
} else {
cur_method = None;
}
continue;
}
if indent == 6 {
let rest = line.trim();
if let Some(after) = rest.strip_prefix("operationId:") {
if let (Some(p), Some(m)) = (&cur_path, &cur_method) {
let id = after.trim().trim_matches(|c| c == '"' || c == '\'');
if !id.is_empty() {
out.insert(format!("{m} {p}"), id.to_string());
}
}
}
continue;
}
}
out
}
fn json_operation_ids_map(json: &Value) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let Some(paths) = json.get("paths").and_then(Value::as_object) else {
return out;
};
for (path, item) in paths {
let Some(item_obj) = item.as_object() else {
continue;
};
for (method, op) in item_obj {
let m = method.to_lowercase();
if !HTTP_METHODS.contains(&m.as_str()) {
continue;
}
if let Some(id) = op.get("operationId").and_then(Value::as_str) {
out.insert(format!("{m} {path}"), id.to_string());
}
}
}
out
}
fn read_yaml() -> String {
let p = yaml_path();
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read openapi YAML at {p:?}: {e}"))
}
fn read_json() -> Value {
let p = json_path();
let raw =
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read openapi JSON at {p:?}: {e}"));
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse openapi JSON at {p:?}: {e}"))
}
#[test]
fn json_carries_do_not_edit_banner() {
let json = read_json();
let comment = json
.get("x-comment")
.and_then(Value::as_str)
.unwrap_or_else(|| {
panic!(
"openapi.json is missing the `x-comment` regen banner. \
Re-run scripts/regen-openapi-json.sh (or .ps1) to refresh \
the JSON from openapi/tensor-wasm-api.yaml."
)
});
assert!(
comment.contains("Generated from openapi/tensor-wasm-api.yaml")
&& comment.contains("Do NOT edit by hand"),
"openapi.json `_comment` does not look like the regen banner: {comment:?}",
);
}
#[test]
fn json_paths_match_yaml_paths() {
let yaml = read_yaml();
let yaml_paths = parse_yaml_path_keys(&yaml);
assert!(
!yaml_paths.is_empty(),
"YAML scanner returned zero paths -- spec file shape changed? \
see openapi/tensor-wasm-api.yaml header for the assumed layout.",
);
let json = read_json();
let json_paths_obj = json
.get("paths")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("openapi.json is missing the `paths` object"));
let json_paths: BTreeSet<String> = json_paths_obj.keys().cloned().collect();
assert_eq!(
json_paths, yaml_paths,
"openapi.json `paths` keys disagree with openapi/tensor-wasm-api.yaml. \
The YAML is authoritative -- run scripts/regen-openapi-json.sh \
(or scripts/regen-openapi-json.ps1 on Windows) to refresh the JSON.",
);
}
#[test]
fn json_operation_security_matches_yaml() {
let yaml = read_yaml();
let yaml_sec = parse_yaml_operation_security(&yaml);
assert!(
!yaml_sec.is_empty(),
"YAML security scanner returned zero operations -- spec file shape \
changed? see openapi/tensor-wasm-api.yaml header for the assumed \
indentation layout.",
);
let json = read_json();
let json_sec = json_operation_security_map(&json);
let yaml_ops: BTreeSet<&String> = yaml_sec.keys().collect();
let json_ops: BTreeSet<&String> = json_sec.keys().collect();
assert_eq!(
json_ops, yaml_ops,
"openapi.json and openapi/tensor-wasm-api.yaml describe different \
(method, path) operation sets. The YAML is authoritative -- run \
scripts/regen-openapi-json.{{sh,ps1}} to refresh the JSON.",
);
let mut mismatches: Vec<String> = Vec::new();
for (op, yaml_spec) in &yaml_sec {
let json_spec = json_sec
.get(op)
.expect("operation sets already asserted equal");
if yaml_spec != json_spec {
mismatches.push(format!(" {op}: yaml={yaml_spec:?} json={json_spec:?}"));
}
}
assert!(
mismatches.is_empty(),
"per-operation `security` drift between openapi/tensor-wasm-api.yaml \
and openapi.json (security: [] means \"no auth\"; absent means \
\"inherit global BearerAuth\" -- these are distinct). The YAML is \
authoritative; run scripts/regen-openapi-json.{{sh,ps1}}:\n{}",
mismatches.join("\n"),
);
}
#[test]
fn json_operation_ids_match_yaml() {
let yaml = read_yaml();
let yaml_ids = parse_yaml_operation_ids(&yaml);
assert!(
!yaml_ids.is_empty(),
"YAML operationId scanner returned nothing -- spec shape changed?",
);
let json = read_json();
let json_ids = json_operation_ids_map(&json);
assert_eq!(
json_ids, yaml_ids,
"per-operation `operationId` drift between openapi/tensor-wasm-api.yaml \
and openapi.json. The YAML is authoritative; run \
scripts/regen-openapi-json.{{sh,ps1}}.",
);
}
#[test]
fn json_info_title_and_version_match_yaml() {
let yaml = read_yaml();
let yaml_title =
scan_info_scalar(&yaml, "title").unwrap_or_else(|| panic!("YAML missing info.title"));
let yaml_version =
scan_info_scalar(&yaml, "version").unwrap_or_else(|| panic!("YAML missing info.version"));
let json = read_json();
let info = json
.get("info")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("openapi.json is missing `info` object"));
let json_title = info
.get("title")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("openapi.json info.title is not a string"));
let json_version = info
.get("version")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("openapi.json info.version is not a string"));
assert_eq!(
json_title, yaml_title,
"openapi.json info.title disagrees with the YAML; run scripts/regen-openapi-json.{{sh,ps1}}",
);
assert_eq!(
json_version, yaml_version,
"openapi.json info.version disagrees with the YAML; run scripts/regen-openapi-json.{{sh,ps1}}",
);
}
fn scan_info_scalar(yaml: &str, key: &str) -> Option<String> {
let mut in_info = false;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_info = line == "info:";
continue;
}
if !in_info {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') {
let needle = format!("{key}:");
if let Some(value) = rest.strip_prefix(&needle) {
let trimmed = value.trim().trim_matches(|c| c == '"' || c == '\'');
if trimmed.is_empty() {
return None;
}
return Some(trimmed.to_string());
}
}
}
}
None
}
#[test]
fn yaml_path_scanner_extracts_keys() {
let yaml = "\
paths:
/a:
get:
summary: x
/b/{id}:
post:
summary: y
components:
schemas:
Foo:
type: object
";
let paths = parse_yaml_path_keys(yaml);
let expected: BTreeSet<String> = ["/a", "/b/{id}"].iter().map(|s| s.to_string()).collect();
assert_eq!(paths, expected);
}
#[test]
fn yaml_info_scanner_extracts_scalars() {
let yaml = "\
openapi: 3.1.0
info:
title: My API
version: 1.2.3
paths:
/a:
get: {}
";
assert_eq!(scan_info_scalar(yaml, "title"), Some("My API".to_string()));
assert_eq!(scan_info_scalar(yaml, "version"), Some("1.2.3".to_string()));
assert_eq!(scan_info_scalar(yaml, "missing"), None);
}
#[test]
fn yaml_security_scanner_distinguishes_empty_absent_and_schemes() {
let yaml = "\
paths:
/open:
get:
summary: x
security: []
/inherit:
get:
summary: y
/scoped:
post:
summary: z
security:
- BearerAuth: []
- ApiKey: []
components:
schemas:
Foo:
type: object
security: []
";
let sec = parse_yaml_operation_security(yaml);
assert_eq!(sec.get("get /open"), Some(&SecuritySpec::Empty));
assert_eq!(sec.get("get /inherit"), Some(&SecuritySpec::Absent));
let schemes: BTreeSet<String> = ["ApiKey", "BearerAuth"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
sec.get("post /scoped"),
Some(&SecuritySpec::Schemes(schemes))
);
assert_eq!(sec.len(), 3);
}
#[test]
fn json_security_normaliser_distinguishes_empty_absent_and_schemes() {
let absent: Value = serde_json::json!({ "summary": "x" });
assert_eq!(json_operation_security(&absent), SecuritySpec::Absent);
let empty: Value = serde_json::json!({ "security": [] });
assert_eq!(json_operation_security(&empty), SecuritySpec::Empty);
let scoped: Value = serde_json::json!({
"security": [{ "BearerAuth": [] }, { "ApiKey": [] }]
});
let schemes: BTreeSet<String> = ["ApiKey", "BearerAuth"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
json_operation_security(&scoped),
SecuritySpec::Schemes(schemes)
);
}
#[test]
fn yaml_operation_id_scanner_extracts_ids() {
let yaml = "\
paths:
/a:
get:
operationId: getA
summary: x
/b:
post:
summary: y
";
let ids = parse_yaml_operation_ids(yaml);
assert_eq!(ids.get("get /a"), Some(&"getA".to_string()));
assert_eq!(ids.get("post /b"), None);
assert_eq!(ids.len(), 1);
}