use console::Style;
use reqwest::blocking::Client;
use serde_json::Value;
use std::time::Duration;
pub fn validate_openapi_json(json: &Value) -> Result<SpecInfo, String> {
let version = json
.get("openapi")
.and_then(|v| v.as_str())
.ok_or("OpenAPI spec is malformed: missing `openapi` field")?;
if !version.starts_with("3.") {
return Err(format!(
"OpenAPI spec version `{version}` is not supported (expected 3.x)"
));
}
let paths = json
.get("paths")
.and_then(|v| v.as_object())
.ok_or("OpenAPI spec is malformed: missing `paths` field")?;
if paths.is_empty() {
return Err("OpenAPI spec has no API paths defined".to_string());
}
let http_methods = [
"get", "post", "put", "patch", "delete", "head", "options", "trace",
];
let mut operation_count = 0;
for (_path, methods) in paths {
if let Some(obj) = methods.as_object() {
for key in obj.keys() {
if http_methods.contains(&key.as_str()) {
operation_count += 1;
}
}
}
}
if operation_count == 0 {
return Err("OpenAPI spec has paths but no operations defined".to_string());
}
Ok(SpecInfo {
version: version.to_string(),
operation_count,
path_count: paths.len(),
})
}
#[derive(Debug)]
pub struct SpecInfo {
pub version: String,
pub operation_count: usize,
pub path_count: usize,
}
fn find_first_endpoint(json: &Value) -> Option<(String, String)> {
let paths = json.get("paths")?.as_object()?;
let http_methods = [
"get", "post", "put", "patch", "delete", "head", "options", "trace",
];
for (path, methods) in paths {
if let Some(obj) = methods.as_object() {
if obj.contains_key("get") {
return Some((path.clone(), "GET".to_string()));
}
}
}
for (path, methods) in paths {
if let Some(obj) = methods.as_object() {
for key in obj.keys() {
if http_methods.contains(&key.as_str()) {
return Some((path.clone(), key.to_uppercase()));
}
}
}
}
None
}
pub fn run(url: String, api_key: Option<String>, spec_path: String) {
let green = Style::new().green();
let red = Style::new().red();
let dim = Style::new().dim();
let base_url = url.trim_end_matches('/');
let spec_url = format!("{base_url}{spec_path}");
println!("Checking API at {base_url}...\n");
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("Failed to create HTTP client");
match client.get(&spec_url).send() {
Ok(_) => {
println!(" {} Server is running", green.apply_to("\u{2713}"));
}
Err(_) => {
println!(" {} Server not reachable", red.apply_to("\u{2717}"));
println!(
" {} Start your server with: cargo run",
dim.apply_to("\u{2192}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
}
let spec_response = match client.get(&spec_url).send() {
Ok(resp) => resp,
Err(_) => {
println!(
" {} Could not fetch OpenAPI spec",
red.apply_to("\u{2717}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
};
let status = spec_response.status();
if status.as_u16() == 404 {
println!(
" {} OpenAPI spec not found at {spec_path}",
red.apply_to("\u{2717}")
);
println!(
" {} Did you register docs_routes()?",
dim.apply_to("\u{2192}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
if !status.is_success() {
println!(
" {} OpenAPI spec returned HTTP {status}",
red.apply_to("\u{2717}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
let spec_json: Value = match spec_response.json() {
Ok(v) => v,
Err(_) => {
println!(
" {} OpenAPI spec endpoint returned non-JSON response",
red.apply_to("\u{2717}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
};
println!(
" {} OpenAPI spec available at {spec_path}",
green.apply_to("\u{2713}")
);
let spec_info = match validate_openapi_json(&spec_json) {
Ok(info) => info,
Err(msg) => {
println!(" {} {msg}", red.apply_to("\u{2717}"));
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
};
println!(
" {} Valid OpenAPI {} spec \u{2014} {} operations across {} paths",
green.apply_to("\u{2713}"),
spec_info.version,
spec_info.operation_count,
spec_info.path_count,
);
if let Some(ref key) = api_key {
if let Some((endpoint_path, method)) = find_first_endpoint(&spec_json) {
let endpoint_url = format!("{base_url}{endpoint_path}");
let request = match method.as_str() {
"GET" => client.get(&endpoint_url),
"POST" => client.post(&endpoint_url),
"PUT" => client.put(&endpoint_url),
"PATCH" => client.patch(&endpoint_url),
"DELETE" => client.delete(&endpoint_url),
"HEAD" => client.head(&endpoint_url),
_ => client.get(&endpoint_url),
};
match request.header("X-API-Key", key).send() {
Ok(resp) if resp.status().as_u16() == 401 => {
println!(" {} API key rejected", red.apply_to("\u{2717}"));
println!(
" {} Check if the key is valid and not expired.",
dim.apply_to("\u{2192}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
Ok(_) => {
println!(
" {} API key authentication working",
green.apply_to("\u{2713}")
);
}
Err(e) => {
println!(" {} Could not test API key: {e}", red.apply_to("\u{2717}"));
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
}
} else {
println!(
" {} No API endpoints found to test authentication",
red.apply_to("\u{2717}")
);
println!("\n Stopped \u{2014} fix the above issues and try again.");
return;
}
} else {
println!(" {} API key authentication", dim.apply_to("-"));
println!(
" {} Skipped \u{2014} provide --api-key to test authentication",
dim.apply_to("\u{2192}")
);
}
println!();
println!(" Ready for MCP! Configure ferro-api-mcp:");
if let Some(ref key) = api_key {
println!(" ferro-api-mcp --spec-url {spec_url} --api-key {key}");
} else {
println!(" ferro-api-mcp --spec-url {spec_url} --api-key <your-key>");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn valid_spec_returns_correct_info() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "Test", "version": "1.0" },
"paths": {
"/users": {
"get": { "summary": "List users" },
"post": { "summary": "Create user" }
},
"/users/{id}": {
"get": { "summary": "Get user" },
"put": { "summary": "Update user" },
"delete": { "summary": "Delete user" }
}
}
});
let info = validate_openapi_json(&spec).unwrap();
assert_eq!(info.version, "3.1.0");
assert_eq!(info.path_count, 2);
assert_eq!(info.operation_count, 5);
}
#[test]
fn missing_openapi_field_errors() {
let spec = json!({
"info": { "title": "Test" },
"paths": { "/x": { "get": {} } }
});
let err = validate_openapi_json(&spec).unwrap_err();
assert!(err.contains("missing `openapi` field"), "got: {err}");
}
#[test]
fn missing_paths_field_errors() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "Test" }
});
let err = validate_openapi_json(&spec).unwrap_err();
assert!(err.contains("missing `paths` field"), "got: {err}");
}
#[test]
fn empty_paths_errors() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "Test" },
"paths": {}
});
let err = validate_openapi_json(&spec).unwrap_err();
assert!(err.contains("no API paths defined"), "got: {err}");
}
#[test]
fn non_3x_version_errors() {
let spec = json!({
"openapi": "2.0",
"info": { "title": "Test" },
"paths": { "/x": { "get": {} } }
});
let err = validate_openapi_json(&spec).unwrap_err();
assert!(err.contains("not supported"), "got: {err}");
}
#[test]
fn multiple_methods_per_path_counted_correctly() {
let spec = json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0" },
"paths": {
"/a": { "get": {}, "post": {}, "put": {} },
"/b": { "delete": {} },
"/c": { "get": {}, "patch": {} }
}
});
let info = validate_openapi_json(&spec).unwrap();
assert_eq!(info.version, "3.0.3");
assert_eq!(info.path_count, 3);
assert_eq!(info.operation_count, 6);
}
#[test]
fn paths_with_no_operations_errors() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "Test" },
"paths": {
"/a": { "parameters": [] },
"/b": { "summary": "just metadata" }
}
});
let err = validate_openapi_json(&spec).unwrap_err();
assert!(err.contains("no operations defined"), "got: {err}");
}
}