use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecEndpoint {
pub method: String,
pub path: String,
pub operation_id: Option<String>,
pub description: Option<String>,
pub request_schema: Option<String>,
pub response_schema: Option<String>,
pub spec_file: String,
}
#[derive(Debug, Clone)]
pub struct SpecParseResult {
pub endpoints: Vec<SpecEndpoint>,
pub title: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecChannel {
pub channel: String,
pub direction: String,
pub protocol: Option<String>,
pub message_schema: Option<String>,
pub description: Option<String>,
pub operation_id: Option<String>,
pub spec_file: String,
}
#[derive(Debug, Clone)]
pub struct AsyncApiParseResult {
pub channels: Vec<SpecChannel>,
pub title: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SpecFileResult {
OpenApi(SpecParseResult),
AsyncApi(AsyncApiParseResult),
}
const HTTP_METHODS: &[&str] = &["get", "post", "put", "delete", "patch", "options", "head"];
fn read_spec_file(path: &Path) -> Option<serde_json::Value> {
let content = std::fs::read_to_string(path).ok()?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"json" => serde_json::from_str(&content).ok(),
"yaml" | "yml" => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
let json_str = serde_json::to_string(&yaml_val).ok()?;
serde_json::from_str(&json_str).ok()
}
_ => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
return Some(v);
}
let yaml_val: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
let json_str = serde_json::to_string(&yaml_val).ok()?;
serde_json::from_str(&json_str).ok()
}
}
}
fn stringify_schema(value: &serde_json::Value) -> Option<String> {
if value.is_null() {
return None;
}
Some(serde_json::to_string(value).unwrap_or_default())
}
fn extract_info_title(root: &serde_json::Value) -> Option<String> {
root.get("info")?
.get("title")?
.as_str()
.map(|s| s.to_string())
}
fn extract_info_version(root: &serde_json::Value) -> Option<String> {
root.get("info")?
.get("version")?
.as_str()
.map(|s| s.to_string())
}
pub fn parse_openapi(path: &Path) -> Option<SpecParseResult> {
let root = read_spec_file(path)?;
let obj = root.as_object()?;
let is_openapi = obj.contains_key("openapi");
let is_swagger = obj.contains_key("swagger");
if !is_openapi && !is_swagger {
return None;
}
let spec_file = path.to_string_lossy().to_string();
let title = extract_info_title(&root);
let version = extract_info_version(&root);
let mut endpoints = Vec::new();
let paths = match obj.get("paths").and_then(|v| v.as_object()) {
Some(p) => p,
None => {
return Some(SpecParseResult {
endpoints,
title,
version,
})
}
};
for (url_path, path_item) in paths {
let path_obj = match path_item.as_object() {
Some(o) => o,
None => continue,
};
let normalized = super::api_surface::normalize_path_pattern(url_path);
for method in HTTP_METHODS {
let operation = match path_obj.get(*method).and_then(|v| v.as_object()) {
Some(op) => op,
None => continue,
};
let operation_id = operation
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = operation
.get("summary")
.and_then(|v| v.as_str())
.or_else(|| operation.get("description").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let request_schema = if is_swagger {
extract_swagger_request_schema(operation)
} else {
extract_openapi3_request_schema(operation)
};
let response_schema = if is_swagger {
extract_swagger_response_schema(operation)
} else {
extract_openapi3_response_schema(operation)
};
endpoints.push(SpecEndpoint {
method: method.to_uppercase(),
path: normalized.clone(),
operation_id,
description,
request_schema,
response_schema,
spec_file: spec_file.clone(),
});
}
}
Some(SpecParseResult {
endpoints,
title,
version,
})
}
fn extract_openapi3_request_schema(
operation: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let schema = operation
.get("requestBody")?
.get("content")?
.get("application/json")?
.get("schema")?;
stringify_schema(schema)
}
fn extract_openapi3_response_schema(
operation: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let responses = operation.get("responses")?.as_object()?;
for status in &["200", "201"] {
if let Some(schema) = responses
.get(*status)
.and_then(|r| r.get("content"))
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("schema"))
{
return stringify_schema(schema);
}
}
None
}
fn extract_swagger_request_schema(
operation: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let parameters = operation.get("parameters")?.as_array()?;
for param in parameters {
if param.get("in").and_then(|v| v.as_str()) == Some("body") {
if let Some(schema) = param.get("schema") {
return stringify_schema(schema);
}
}
}
None
}
fn extract_swagger_response_schema(
operation: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
let responses = operation.get("responses")?.as_object()?;
for status in &["200", "201"] {
if let Some(schema) = responses.get(*status).and_then(|r| r.get("schema")) {
return stringify_schema(schema);
}
}
None
}
pub fn parse_asyncapi(path: &Path) -> Option<AsyncApiParseResult> {
let root = read_spec_file(path)?;
let obj = root.as_object()?;
if !obj.contains_key("asyncapi") {
return None;
}
let spec_file = path.to_string_lossy().to_string();
let title = extract_info_title(&root);
let version = extract_info_version(&root);
let protocol = detect_asyncapi_protocol(obj);
let asyncapi_version = obj.get("asyncapi").and_then(|v| v.as_str()).unwrap_or("");
let channels = if asyncapi_version.starts_with("3.") {
parse_asyncapi_v3(obj, &spec_file, &protocol)
} else {
parse_asyncapi_v2(obj, &spec_file, &protocol)
};
Some(AsyncApiParseResult {
channels,
title,
version,
})
}
fn detect_asyncapi_protocol(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
let servers = obj.get("servers")?.as_object()?;
let (_name, server) = servers.iter().next()?;
server
.get("protocol")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn parse_asyncapi_v2(
obj: &serde_json::Map<String, serde_json::Value>,
spec_file: &str,
protocol: &Option<String>,
) -> Vec<SpecChannel> {
let mut result = Vec::new();
let channels = match obj.get("channels").and_then(|v| v.as_object()) {
Some(c) => c,
None => return result,
};
for (channel_name, channel_value) in channels {
let channel_obj = match channel_value.as_object() {
Some(o) => o,
None => continue,
};
for direction in &["publish", "subscribe"] {
let operation = match channel_obj.get(*direction).and_then(|v| v.as_object()) {
Some(op) => op,
None => continue,
};
let operation_id = operation
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = operation
.get("description")
.and_then(|v| v.as_str())
.or_else(|| operation.get("summary").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let message_schema = operation
.get("message")
.and_then(|m| m.get("payload"))
.and_then(stringify_schema);
result.push(SpecChannel {
channel: channel_name.clone(),
direction: direction.to_string(),
protocol: protocol.clone(),
message_schema,
description,
operation_id,
spec_file: spec_file.to_string(),
});
}
}
result
}
fn parse_asyncapi_v3(
obj: &serde_json::Map<String, serde_json::Value>,
spec_file: &str,
protocol: &Option<String>,
) -> Vec<SpecChannel> {
let mut result = Vec::new();
let operations = match obj.get("operations").and_then(|v| v.as_object()) {
Some(o) => o,
None => return result,
};
for (_op_name, op_value) in operations {
let operation = match op_value.as_object() {
Some(o) => o,
None => continue,
};
let channel_name = operation
.get("channel")
.and_then(|c| {
if let Some(ref_str) = c.get("$ref").and_then(|v| v.as_str()) {
ref_str.rsplit('/').next().map(|s| s.to_string())
} else {
c.as_str().map(|s| s.to_string())
}
})
.unwrap_or_default();
let direction = match operation.get("action").and_then(|v| v.as_str()) {
Some("send") => "publish".to_string(),
Some("receive") => "subscribe".to_string(),
Some(other) => other.to_string(),
None => continue,
};
let operation_id = operation
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = operation
.get("description")
.and_then(|v| v.as_str())
.or_else(|| operation.get("summary").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let message_schema = extract_v3_message_schema(operation, obj, &channel_name);
result.push(SpecChannel {
channel: channel_name,
direction,
protocol: protocol.clone(),
message_schema,
description,
operation_id,
spec_file: spec_file.to_string(),
});
}
result
}
fn extract_v3_message_schema(
operation: &serde_json::Map<String, serde_json::Value>,
root: &serde_json::Map<String, serde_json::Value>,
channel_name: &str,
) -> Option<String> {
if let Some(messages) = operation.get("messages") {
if let Some(schema) = first_message_payload(messages) {
return Some(schema);
}
}
let channel = root.get("channels")?.get(channel_name)?;
let messages = channel.get("messages")?;
first_message_payload(messages)
}
fn first_message_payload(messages: &serde_json::Value) -> Option<String> {
if let Some(obj) = messages.as_object() {
for (_name, msg) in obj {
if let Some(payload) = msg.get("payload") {
return stringify_schema(payload);
}
}
} else if let Some(arr) = messages.as_array() {
for msg in arr {
if let Some(payload) = msg.get("payload") {
return stringify_schema(payload);
}
}
}
None
}
const SPEC_FILE_NAMES: &[&str] = &[
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
"asyncapi.yaml",
"asyncapi.yml",
"asyncapi.json",
];
pub fn scan_api_specs(root: &Path) -> Vec<SpecFileResult> {
let mut results = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !matches!(ext.as_str(), "json" | "yaml" | "yml") {
continue;
}
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
let is_well_known = SPEC_FILE_NAMES.contains(&file_name.as_str());
if !is_well_known {
if !peek_is_spec_file(path) {
continue;
}
}
if let Some(openapi) = parse_openapi(path) {
results.push(SpecFileResult::OpenApi(openapi));
} else if let Some(asyncapi) = parse_asyncapi(path) {
results.push(SpecFileResult::AsyncApi(asyncapi));
}
}
results
}
fn peek_is_spec_file(path: &Path) -> bool {
let mut buf = [0u8; 200];
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
use std::io::Read;
let mut reader = std::io::BufReader::new(file);
let n = match reader.read(&mut buf) {
Ok(n) => n,
Err(_) => return false,
};
let snippet = String::from_utf8_lossy(&buf[..n]).to_lowercase();
snippet.contains("\"openapi\"")
|| snippet.contains("\"swagger\"")
|| snippet.contains("\"asyncapi\"")
|| snippet.contains("openapi:")
|| snippet.contains("swagger:")
|| snippet.contains("asyncapi:")
}
#[cfg(test)]
#[path = "tests/spec_parser_tests.rs"]
mod tests;