use crate::index::symbol::{Reference, ReferenceKind, Symbol, SymbolKind};
use std::sync::LazyLock;
static RE_QUOTED_STRING: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"["']([^"']+)["']"#).unwrap());
static RE_METHODS_PARAM: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"methods\s*=\s*\[([^\]]+)\]"#).unwrap());
static RE_NESTJS_METHOD: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^@(Get|Post|Put|Delete|Patch|Head|Options)\b").unwrap());
static RE_FLASK_PARAM: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").unwrap());
static RE_EXPRESS_PARAM: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r":(\w+)").unwrap());
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedEndpoint {
pub id: String,
pub method: Option<String>,
pub path: String,
pub handler: String,
pub file_path: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedClientCall {
pub caller: String,
pub method: Option<String>,
pub url_pattern: Option<String>,
pub client_library: String,
pub file_path: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedEventCall {
pub caller: String,
pub channel: Option<String>,
pub direction: String,
pub protocol: String,
pub file_path: String,
pub line: usize,
}
#[derive(Debug, Default)]
pub struct ApiSurfaceResult {
pub endpoints: Vec<DetectedEndpoint>,
pub client_calls: Vec<DetectedClientCall>,
pub event_calls: Vec<DetectedEventCall>,
}
pub fn detect_endpoints(symbols: &[Symbol], namespace: &str) -> Vec<DetectedEndpoint> {
let mut endpoints = Vec::new();
for sym in symbols {
for attr in &sym.attributes {
if let Some(ep) = parse_route_decorator(attr, sym, namespace) {
endpoints.push(ep);
}
}
if is_django_view_class(sym) {
for method in &["get", "post", "put", "patch", "delete"] {
if sym.kind == SymbolKind::Method && sym.name == *method {
if let Some(parent) = &sym.parent {
endpoints.push(DetectedEndpoint {
id: format!("ep:{namespace}:{}:view:{parent}", method.to_uppercase()),
method: Some(method.to_uppercase()),
path: format!("view:{parent}"), handler: sym.qualified_name.clone(),
file_path: sym.file_path.clone(),
line: sym.line_start,
});
}
}
}
}
}
endpoints
}
fn parse_route_decorator(attr: &str, sym: &Symbol, namespace: &str) -> Option<DetectedEndpoint> {
let attr_lower = attr.to_lowercase();
if attr_lower.contains("route(")
|| attr_lower.contains(".get(")
|| attr_lower.contains(".post(")
|| attr_lower.contains(".put(")
|| attr_lower.contains(".delete(")
|| attr_lower.contains(".patch(")
{
let method = extract_http_method_from_decorator(attr);
let path = extract_path_from_decorator(attr)?;
let normalized_path = normalize_path_pattern(&path);
return Some(DetectedEndpoint {
id: format!(
"ep:{namespace}:{}:{normalized_path}",
method.as_deref().unwrap_or("ANY")
),
method,
path: normalized_path,
handler: sym.qualified_name.clone(),
file_path: sym.file_path.clone(),
line: sym.line_start,
});
}
if attr_lower.contains("mapping(") || attr_lower.contains("mapping\"") {
let method = extract_spring_method(attr);
let path = extract_path_from_decorator(attr)?;
let normalized_path = normalize_path_pattern(&path);
return Some(DetectedEndpoint {
id: format!(
"ep:{namespace}:{}:{normalized_path}",
method.as_deref().unwrap_or("ANY")
),
method,
path: normalized_path,
handler: sym.qualified_name.clone(),
file_path: sym.file_path.clone(),
line: sym.line_start,
});
}
if let Some(method) = extract_nestjs_method(attr) {
let path = extract_path_from_decorator(attr).unwrap_or_else(|| "/".to_string());
let normalized_path = normalize_path_pattern(&path);
return Some(DetectedEndpoint {
id: format!("ep:{namespace}:{method}:{normalized_path}"),
method: Some(method),
path: normalized_path,
handler: sym.qualified_name.clone(),
file_path: sym.file_path.clone(),
line: sym.line_start,
});
}
None
}
pub fn detect_client_calls(references: &[Reference]) -> Vec<DetectedClientCall> {
let mut calls = Vec::new();
for r in references {
if r.kind != ReferenceKind::Call {
continue;
}
if let Some(call) = parse_client_call(&r.target_name, r) {
calls.push(call);
}
}
calls
}
fn parse_client_call(target: &str, reference: &Reference) -> Option<DetectedClientCall> {
let target_lower = target.to_lowercase();
if target_lower.starts_with("requests.") || target_lower.starts_with("httpx.") {
let parts: Vec<&str> = target.splitn(2, '.').collect();
let library = parts[0].to_string();
let method = parts.get(1).and_then(|m| http_method_from_name(m));
return Some(DetectedClientCall {
caller: reference.source_qualified_name.clone(),
method,
url_pattern: None, client_library: library,
file_path: reference.file_path.clone(),
line: reference.line,
});
}
if target_lower == "fetch" {
return Some(DetectedClientCall {
caller: reference.source_qualified_name.clone(),
method: None, url_pattern: None,
client_library: "fetch".to_string(),
file_path: reference.file_path.clone(),
line: reference.line,
});
}
if target_lower.starts_with("axios.") {
let method = target.split('.').nth(1).and_then(http_method_from_name);
return Some(DetectedClientCall {
caller: reference.source_qualified_name.clone(),
method,
url_pattern: None,
client_library: "axios".to_string(),
file_path: reference.file_path.clone(),
line: reference.line,
});
}
if target_lower.starts_with("http.")
&& (target.contains("Get")
|| target.contains("Post")
|| target.contains("NewRequest")
|| target.contains("Do"))
{
let method = if target.contains("Get") {
Some("GET".to_string())
} else if target.contains("Post") {
Some("POST".to_string())
} else {
None
};
return Some(DetectedClientCall {
caller: reference.source_qualified_name.clone(),
method,
url_pattern: None,
client_library: "net/http".to_string(),
file_path: reference.file_path.clone(),
line: reference.line,
});
}
if target_lower.contains("resttemplate")
|| target_lower.contains("webclient")
|| target_lower.contains("httpclient")
{
return Some(DetectedClientCall {
caller: reference.source_qualified_name.clone(),
method: None,
url_pattern: None,
client_library: target.split('.').next().unwrap_or(target).to_string(),
file_path: reference.file_path.clone(),
line: reference.line,
});
}
None
}
fn extract_path_from_decorator(attr: &str) -> Option<String> {
RE_QUOTED_STRING.captures(attr).map(|c| c[1].to_string())
}
fn extract_http_method_from_decorator(attr: &str) -> Option<String> {
let attr_lower = attr.to_lowercase();
for method in &["get", "post", "put", "delete", "patch", "head", "options"] {
if attr_lower.contains(&format!(".{method}(")) {
return Some(method.to_uppercase());
}
}
if attr_lower.contains("route(") {
if let Some(methods) = extract_methods_param(attr) {
return methods.first().cloned();
}
}
None
}
fn extract_methods_param(attr: &str) -> Option<Vec<String>> {
let caps = RE_METHODS_PARAM.captures(attr)?;
let methods_str = &caps[1];
let methods: Vec<String> = methods_str
.split(',')
.map(|m| {
m.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_uppercase()
})
.filter(|m| !m.is_empty())
.collect();
if methods.is_empty() {
None
} else {
Some(methods)
}
}
fn extract_spring_method(attr: &str) -> Option<String> {
let attr_lower = attr.to_lowercase();
if attr_lower.contains("getmapping") {
return Some("GET".to_string());
}
if attr_lower.contains("postmapping") {
return Some("POST".to_string());
}
if attr_lower.contains("putmapping") {
return Some("PUT".to_string());
}
if attr_lower.contains("deletemapping") {
return Some("DELETE".to_string());
}
if attr_lower.contains("patchmapping") {
return Some("PATCH".to_string());
}
if attr_lower.contains("requestmapping") {
if attr_lower.contains("get") {
return Some("GET".to_string());
}
if attr_lower.contains("post") {
return Some("POST".to_string());
}
}
None
}
fn extract_nestjs_method(attr: &str) -> Option<String> {
RE_NESTJS_METHOD.captures(attr).map(|c| c[1].to_uppercase())
}
pub fn normalize_path_pattern(path: &str) -> String {
let mut result = path.to_string();
result = RE_FLASK_PARAM.replace_all(&result, "{$1}").to_string();
let express_re = &*RE_EXPRESS_PARAM;
result = express_re.replace_all(&result, "{$1}").to_string();
if !result.starts_with('/') {
result = format!("/{result}");
}
if result.len() > 1 && result.ends_with('/') {
result.pop();
}
result
}
fn is_django_view_class(sym: &Symbol) -> bool {
if sym.kind != SymbolKind::Method {
return false;
}
sym.parent
.as_ref()
.is_some_and(|p| p.ends_with("View") || p.ends_with("ViewSet") || p.ends_with("APIView"))
}
fn http_method_from_name(name: &str) -> Option<String> {
match name.to_lowercase().as_str() {
"get" => Some("GET".to_string()),
"post" => Some("POST".to_string()),
"put" => Some("PUT".to_string()),
"delete" => Some("DELETE".to_string()),
"patch" => Some("PATCH".to_string()),
"head" => Some("HEAD".to_string()),
"options" => Some("OPTIONS".to_string()),
_ => None,
}
}
pub fn match_endpoint<'a>(
url_path: &str,
method: Option<&str>,
endpoints: &'a [DetectedEndpoint],
) -> Option<(&'a DetectedEndpoint, f64)> {
let normalized = normalize_path_pattern(url_path);
let mut best: Option<(&DetectedEndpoint, f64)> = None;
for ep in endpoints {
let base_confidence: f64 = if ep.path == normalized {
1.0
} else if paths_match_with_params(&normalized, &ep.path) {
0.9
} else if normalized.starts_with(&ep.path) || ep.path.starts_with(&normalized) {
0.7
} else {
continue;
};
let mut confidence = base_confidence;
if let (Some(call_method), Some(ep_method)) = (method, ep.method.as_deref()) {
if call_method.eq_ignore_ascii_case(ep_method) {
confidence += 0.05;
} else {
confidence -= 0.1;
}
}
confidence = confidence.clamp(0.0, 1.0);
if best.is_none() || confidence > best.unwrap().1 {
best = Some((ep, confidence));
}
}
best.filter(|(_, c)| *c >= 0.5)
}
fn paths_match_with_params(actual: &str, pattern: &str) -> bool {
let actual_parts: Vec<&str> = actual.split('/').collect();
let pattern_parts: Vec<&str> = pattern.split('/').collect();
if actual_parts.len() != pattern_parts.len() {
return false;
}
actual_parts
.iter()
.zip(pattern_parts.iter())
.all(|(a, p)| a == p || (p.starts_with('{') && p.ends_with('}')))
}
const ROUTE_REGISTRATION_PATTERNS: &[(&str, &str)] = &[
("http.HandleFunc", "go-http"),
("http.Handle", "go-http"),
("router.GET", "gin"),
("router.POST", "gin"),
("router.PUT", "gin"),
("router.DELETE", "gin"),
("router.PATCH", "gin"),
("e.GET", "echo"),
("e.POST", "echo"),
("e.PUT", "echo"),
("e.DELETE", "echo"),
("e.PATCH", "echo"),
("mux.Handle", "mux"),
("mux.HandleFunc", "mux"),
("app.get", "express"),
("app.post", "express"),
("app.put", "express"),
("app.delete", "express"),
("app.patch", "express"),
("router.get", "express"),
("router.post", "express"),
("router.put", "express"),
("router.delete", "express"),
("router.patch", "express"),
];
pub fn detect_endpoints_from_references(
references: &[Reference],
namespace: &str,
) -> Vec<DetectedEndpoint> {
let mut endpoints = Vec::new();
for r in references {
if r.kind != ReferenceKind::Call {
continue;
}
for &(pattern, _framework) in ROUTE_REGISTRATION_PATTERNS {
if r.target_name == pattern
|| r.target_name.ends_with(&format!(
".{}",
pattern.split('.').next_back().unwrap_or("")
))
{
let method = extract_method_from_call_target(&r.target_name);
let handler_path = format!("handler:{}", r.source_qualified_name);
endpoints.push(DetectedEndpoint {
id: format!(
"ep:{namespace}:{}:{handler_path}",
method.as_deref().unwrap_or("ANY")
),
method,
path: handler_path,
handler: r.source_qualified_name.clone(),
file_path: r.file_path.clone(),
line: r.line,
});
break;
}
}
}
endpoints
}
fn extract_method_from_call_target(target: &str) -> Option<String> {
let last_segment = target.rsplit('.').next().unwrap_or(target);
match last_segment {
"GET" | "get" => Some("GET".to_string()),
"POST" | "post" => Some("POST".to_string()),
"PUT" | "put" => Some("PUT".to_string()),
"DELETE" | "delete" => Some("DELETE".to_string()),
"PATCH" | "patch" => Some("PATCH".to_string()),
_ => None,
}
}
const EVENT_PATTERNS: &[(&str, &str, &str)] = &[
("producer.send", "kafka", "publish"),
("producer.produce", "kafka", "publish"),
("consumer.subscribe", "kafka", "subscribe"),
("consumer.poll", "kafka", "subscribe"),
("channel.basic_publish", "rabbitmq", "publish"),
("channel.basic_consume", "rabbitmq", "subscribe"),
("channel.publish", "rabbitmq", "publish"),
("channel.consume", "rabbitmq", "subscribe"),
("redis.publish", "redis", "publish"),
("redis.subscribe", "redis", "subscribe"),
("client.publish", "redis", "publish"),
("client.subscribe", "redis", "subscribe"),
("sqs.send_message", "sqs", "publish"),
("sqs.receive_message", "sqs", "subscribe"),
("sqs.sendMessage", "sqs", "publish"),
("sqs.receiveMessage", "sqs", "subscribe"),
("sns.publish", "sns", "publish"),
("nc.publish", "nats", "publish"),
("nc.subscribe", "nats", "subscribe"),
("nats.publish", "nats", "publish"),
("nats.subscribe", "nats", "subscribe"),
("emitter.emit", "event", "publish"),
("emitter.on", "event", "subscribe"),
];
const EVENT_ANNOTATION_PATTERNS: &[(&str, &str, &str)] = &[
("KafkaListener", "kafka", "subscribe"),
("RabbitListener", "rabbitmq", "subscribe"),
("SqsListener", "sqs", "subscribe"),
("JmsListener", "jms", "subscribe"),
("EventListener", "event", "subscribe"),
];
pub fn detect_event_calls(references: &[Reference], symbols: &[Symbol]) -> Vec<DetectedEventCall> {
let mut events = Vec::new();
for r in references {
if r.kind != ReferenceKind::Call {
continue;
}
for &(pattern, protocol, direction) in EVENT_PATTERNS {
if r.target_name == pattern || r.target_name.ends_with(pattern) {
events.push(DetectedEventCall {
caller: r.source_qualified_name.clone(),
channel: None, direction: direction.to_string(),
protocol: protocol.to_string(),
file_path: r.file_path.clone(),
line: r.line,
});
break;
}
}
}
for sym in symbols {
for attr in &sym.attributes {
for &(annotation, protocol, direction) in EVENT_ANNOTATION_PATTERNS {
if attr.contains(annotation) {
events.push(DetectedEventCall {
caller: sym.qualified_name.clone(),
channel: extract_path_from_decorator(attr),
direction: direction.to_string(),
protocol: protocol.to_string(),
file_path: sym.file_path.clone(),
line: sym.line_start,
});
break;
}
}
}
}
events
}
pub fn match_event_channels(
producers: &[DetectedEventCall],
consumers: &[DetectedEventCall],
) -> Vec<(String, String, String, String, f64)> {
let mut matches = Vec::new();
for producer in producers {
for consumer in consumers {
if producer.protocol != consumer.protocol {
continue;
}
match (&producer.channel, &consumer.channel) {
(Some(pc), Some(cc)) => {
let confidence = if pc == cc {
0.95
} else if channels_match_pattern(pc, cc) {
0.8
} else {
continue;
};
matches.push((
producer.caller.clone(),
consumer.caller.clone(),
pc.clone(),
producer.protocol.clone(),
confidence,
));
}
_ => continue,
}
}
}
matches
}
fn channels_match_pattern(a: &str, b: &str) -> bool {
if a.contains('*') || a.contains('#') {
let prefix = a.trim_end_matches(['*', '#', '.']);
b.starts_with(prefix)
} else if b.contains('*') || b.contains('#') {
let prefix = b.trim_end_matches(['*', '#', '.']);
a.starts_with(prefix)
} else {
false
}
}
#[cfg(test)]
#[path = "tests/api_surface_tests.rs"]
mod tests;