1use crate::index::symbol::{Reference, ReferenceKind, Symbol, SymbolKind};
7use std::sync::LazyLock;
8
9static RE_QUOTED_STRING: LazyLock<regex::Regex> =
12 LazyLock::new(|| regex::Regex::new(r#"["']([^"']+)["']"#).unwrap());
13
14static RE_METHODS_PARAM: LazyLock<regex::Regex> =
15 LazyLock::new(|| regex::Regex::new(r#"methods\s*=\s*\[([^\]]+)\]"#).unwrap());
16
17static RE_NESTJS_METHOD: LazyLock<regex::Regex> =
18 LazyLock::new(|| regex::Regex::new(r"^@(Get|Post|Put|Delete|Patch|Head|Options)\b").unwrap());
19
20static RE_FLASK_PARAM: LazyLock<regex::Regex> =
21 LazyLock::new(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").unwrap());
22
23static RE_EXPRESS_PARAM: LazyLock<regex::Regex> =
24 LazyLock::new(|| regex::Regex::new(r":(\w+)").unwrap());
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct DetectedEndpoint {
29 pub id: String,
31 pub method: Option<String>,
33 pub path: String,
35 pub handler: String,
37 pub file_path: String,
39 pub line: usize,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct DetectedClientCall {
46 pub caller: String,
48 pub method: Option<String>,
50 pub url_pattern: Option<String>,
52 pub client_library: String,
54 pub file_path: String,
56 pub line: usize,
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub struct DetectedEventCall {
63 pub caller: String,
65 pub channel: Option<String>,
67 pub direction: String,
69 pub protocol: String,
71 pub file_path: String,
73 pub line: usize,
75}
76
77#[derive(Debug, Default)]
79pub struct ApiSurfaceResult {
80 pub endpoints: Vec<DetectedEndpoint>,
81 pub client_calls: Vec<DetectedClientCall>,
82 pub event_calls: Vec<DetectedEventCall>,
83}
84
85pub fn detect_endpoints(symbols: &[Symbol], namespace: &str) -> Vec<DetectedEndpoint> {
93 let mut endpoints = Vec::new();
94
95 for sym in symbols {
96 for attr in &sym.attributes {
98 if let Some(ep) = parse_route_decorator(attr, sym, namespace) {
99 endpoints.push(ep);
100 }
101 }
102
103 if is_django_view_class(sym) {
105 for method in &["get", "post", "put", "patch", "delete"] {
108 if sym.kind == SymbolKind::Method && sym.name == *method {
109 if let Some(parent) = &sym.parent {
110 endpoints.push(DetectedEndpoint {
111 id: format!("ep:{namespace}:{}:view:{parent}", method.to_uppercase()),
112 method: Some(method.to_uppercase()),
113 path: format!("view:{parent}"), handler: sym.qualified_name.clone(),
115 file_path: sym.file_path.clone(),
116 line: sym.line_start,
117 });
118 }
119 }
120 }
121 }
122 }
123
124 endpoints
125}
126
127fn parse_route_decorator(attr: &str, sym: &Symbol, namespace: &str) -> Option<DetectedEndpoint> {
136 let attr_lower = attr.to_lowercase();
137
138 if attr_lower.contains("route(")
140 || attr_lower.contains(".get(")
141 || attr_lower.contains(".post(")
142 || attr_lower.contains(".put(")
143 || attr_lower.contains(".delete(")
144 || attr_lower.contains(".patch(")
145 {
146 let method = extract_http_method_from_decorator(attr);
147 let path = extract_path_from_decorator(attr)?;
148 let normalized_path = normalize_path_pattern(&path);
149
150 return Some(DetectedEndpoint {
151 id: format!(
152 "ep:{namespace}:{}:{normalized_path}",
153 method.as_deref().unwrap_or("ANY")
154 ),
155 method,
156 path: normalized_path,
157 handler: sym.qualified_name.clone(),
158 file_path: sym.file_path.clone(),
159 line: sym.line_start,
160 });
161 }
162
163 if attr_lower.contains("mapping(") || attr_lower.contains("mapping\"") {
165 let method = extract_spring_method(attr);
166 let path = extract_path_from_decorator(attr)?;
167 let normalized_path = normalize_path_pattern(&path);
168
169 return Some(DetectedEndpoint {
170 id: format!(
171 "ep:{namespace}:{}:{normalized_path}",
172 method.as_deref().unwrap_or("ANY")
173 ),
174 method,
175 path: normalized_path,
176 handler: sym.qualified_name.clone(),
177 file_path: sym.file_path.clone(),
178 line: sym.line_start,
179 });
180 }
181
182 if let Some(method) = extract_nestjs_method(attr) {
184 let path = extract_path_from_decorator(attr).unwrap_or_else(|| "/".to_string());
185 let normalized_path = normalize_path_pattern(&path);
186
187 return Some(DetectedEndpoint {
188 id: format!("ep:{namespace}:{method}:{normalized_path}"),
189 method: Some(method),
190 path: normalized_path,
191 handler: sym.qualified_name.clone(),
192 file_path: sym.file_path.clone(),
193 line: sym.line_start,
194 });
195 }
196
197 None
198}
199
200pub fn detect_client_calls(references: &[Reference]) -> Vec<DetectedClientCall> {
208 let mut calls = Vec::new();
209
210 for r in references {
211 if r.kind != ReferenceKind::Call {
212 continue;
213 }
214
215 if let Some(call) = parse_client_call(&r.target_name, r) {
216 calls.push(call);
217 }
218 }
219
220 calls
221}
222
223fn parse_client_call(target: &str, reference: &Reference) -> Option<DetectedClientCall> {
224 let target_lower = target.to_lowercase();
225
226 if target_lower.starts_with("requests.") || target_lower.starts_with("httpx.") {
228 let parts: Vec<&str> = target.splitn(2, '.').collect();
229 let library = parts[0].to_string();
230 let method = parts.get(1).and_then(|m| http_method_from_name(m));
231
232 return Some(DetectedClientCall {
233 caller: reference.source_qualified_name.clone(),
234 method,
235 url_pattern: None, client_library: library,
237 file_path: reference.file_path.clone(),
238 line: reference.line,
239 });
240 }
241
242 if target_lower == "fetch" {
244 return Some(DetectedClientCall {
245 caller: reference.source_qualified_name.clone(),
246 method: None, url_pattern: None,
248 client_library: "fetch".to_string(),
249 file_path: reference.file_path.clone(),
250 line: reference.line,
251 });
252 }
253
254 if target_lower.starts_with("axios.") {
256 let method = target.split('.').nth(1).and_then(http_method_from_name);
257 return Some(DetectedClientCall {
258 caller: reference.source_qualified_name.clone(),
259 method,
260 url_pattern: None,
261 client_library: "axios".to_string(),
262 file_path: reference.file_path.clone(),
263 line: reference.line,
264 });
265 }
266
267 if target_lower.starts_with("http.")
269 && (target.contains("Get")
270 || target.contains("Post")
271 || target.contains("NewRequest")
272 || target.contains("Do"))
273 {
274 let method = if target.contains("Get") {
275 Some("GET".to_string())
276 } else if target.contains("Post") {
277 Some("POST".to_string())
278 } else {
279 None
280 };
281 return Some(DetectedClientCall {
282 caller: reference.source_qualified_name.clone(),
283 method,
284 url_pattern: None,
285 client_library: "net/http".to_string(),
286 file_path: reference.file_path.clone(),
287 line: reference.line,
288 });
289 }
290
291 if target_lower.contains("resttemplate")
293 || target_lower.contains("webclient")
294 || target_lower.contains("httpclient")
295 {
296 return Some(DetectedClientCall {
297 caller: reference.source_qualified_name.clone(),
298 method: None,
299 url_pattern: None,
300 client_library: target.split('.').next().unwrap_or(target).to_string(),
301 file_path: reference.file_path.clone(),
302 line: reference.line,
303 });
304 }
305
306 None
307}
308
309fn extract_path_from_decorator(attr: &str) -> Option<String> {
313 RE_QUOTED_STRING.captures(attr).map(|c| c[1].to_string())
314}
315
316fn extract_http_method_from_decorator(attr: &str) -> Option<String> {
318 let attr_lower = attr.to_lowercase();
319 for method in &["get", "post", "put", "delete", "patch", "head", "options"] {
320 if attr_lower.contains(&format!(".{method}(")) {
322 return Some(method.to_uppercase());
323 }
324 }
325 if attr_lower.contains("route(") {
327 if let Some(methods) = extract_methods_param(attr) {
328 return methods.first().cloned();
329 }
330 }
331 None
332}
333
334fn extract_methods_param(attr: &str) -> Option<Vec<String>> {
336 let caps = RE_METHODS_PARAM.captures(attr)?;
337 let methods_str = &caps[1];
338 let methods: Vec<String> = methods_str
339 .split(',')
340 .map(|m| {
341 m.trim()
342 .trim_matches(|c| c == '"' || c == '\'')
343 .to_uppercase()
344 })
345 .filter(|m| !m.is_empty())
346 .collect();
347 if methods.is_empty() {
348 None
349 } else {
350 Some(methods)
351 }
352}
353
354fn extract_spring_method(attr: &str) -> Option<String> {
356 let attr_lower = attr.to_lowercase();
357 if attr_lower.contains("getmapping") {
358 return Some("GET".to_string());
359 }
360 if attr_lower.contains("postmapping") {
361 return Some("POST".to_string());
362 }
363 if attr_lower.contains("putmapping") {
364 return Some("PUT".to_string());
365 }
366 if attr_lower.contains("deletemapping") {
367 return Some("DELETE".to_string());
368 }
369 if attr_lower.contains("patchmapping") {
370 return Some("PATCH".to_string());
371 }
372 if attr_lower.contains("requestmapping") {
374 if attr_lower.contains("get") {
375 return Some("GET".to_string());
376 }
377 if attr_lower.contains("post") {
378 return Some("POST".to_string());
379 }
380 }
381 None
382}
383
384fn extract_nestjs_method(attr: &str) -> Option<String> {
386 RE_NESTJS_METHOD.captures(attr).map(|c| c[1].to_uppercase())
389}
390
391pub fn normalize_path_pattern(path: &str) -> String {
397 let mut result = path.to_string();
398
399 result = RE_FLASK_PARAM.replace_all(&result, "{$1}").to_string();
401
402 let express_re = &*RE_EXPRESS_PARAM;
404 result = express_re.replace_all(&result, "{$1}").to_string();
405
406 if !result.starts_with('/') {
408 result = format!("/{result}");
409 }
410
411 if result.len() > 1 && result.ends_with('/') {
413 result.pop();
414 }
415
416 result
417}
418
419fn is_django_view_class(sym: &Symbol) -> bool {
421 if sym.kind != SymbolKind::Method {
422 return false;
423 }
424 sym.parent
426 .as_ref()
427 .is_some_and(|p| p.ends_with("View") || p.ends_with("ViewSet") || p.ends_with("APIView"))
428}
429
430fn http_method_from_name(name: &str) -> Option<String> {
432 match name.to_lowercase().as_str() {
433 "get" => Some("GET".to_string()),
434 "post" => Some("POST".to_string()),
435 "put" => Some("PUT".to_string()),
436 "delete" => Some("DELETE".to_string()),
437 "patch" => Some("PATCH".to_string()),
438 "head" => Some("HEAD".to_string()),
439 "options" => Some("OPTIONS".to_string()),
440 _ => None,
441 }
442}
443
444pub fn match_endpoint<'a>(
448 url_path: &str,
449 method: Option<&str>,
450 endpoints: &'a [DetectedEndpoint],
451) -> Option<(&'a DetectedEndpoint, f64)> {
452 let normalized = normalize_path_pattern(url_path);
453 let mut best: Option<(&DetectedEndpoint, f64)> = None;
454
455 for ep in endpoints {
456 let base_confidence: f64 = if ep.path == normalized {
458 1.0
459 } else if paths_match_with_params(&normalized, &ep.path) {
460 0.9
461 } else if normalized.starts_with(&ep.path) || ep.path.starts_with(&normalized) {
462 0.7
463 } else {
464 continue;
465 };
466
467 let mut confidence = base_confidence;
468
469 if let (Some(call_method), Some(ep_method)) = (method, ep.method.as_deref()) {
471 if call_method.eq_ignore_ascii_case(ep_method) {
472 confidence += 0.05;
473 } else {
474 confidence -= 0.1;
475 }
476 }
477
478 confidence = confidence.clamp(0.0, 1.0);
479
480 if best.is_none() || confidence > best.unwrap().1 {
481 best = Some((ep, confidence));
482 }
483 }
484
485 best.filter(|(_, c)| *c >= 0.5)
487}
488
489fn paths_match_with_params(actual: &str, pattern: &str) -> bool {
492 let actual_parts: Vec<&str> = actual.split('/').collect();
493 let pattern_parts: Vec<&str> = pattern.split('/').collect();
494
495 if actual_parts.len() != pattern_parts.len() {
496 return false;
497 }
498
499 actual_parts
500 .iter()
501 .zip(pattern_parts.iter())
502 .all(|(a, p)| a == p || (p.starts_with('{') && p.ends_with('}')))
503}
504
505const ROUTE_REGISTRATION_PATTERNS: &[(&str, &str)] = &[
509 ("http.HandleFunc", "go-http"),
511 ("http.Handle", "go-http"),
512 ("router.GET", "gin"),
514 ("router.POST", "gin"),
515 ("router.PUT", "gin"),
516 ("router.DELETE", "gin"),
517 ("router.PATCH", "gin"),
518 ("e.GET", "echo"),
520 ("e.POST", "echo"),
521 ("e.PUT", "echo"),
522 ("e.DELETE", "echo"),
523 ("e.PATCH", "echo"),
524 ("mux.Handle", "mux"),
526 ("mux.HandleFunc", "mux"),
527 ("app.get", "express"),
529 ("app.post", "express"),
530 ("app.put", "express"),
531 ("app.delete", "express"),
532 ("app.patch", "express"),
533 ("router.get", "express"),
534 ("router.post", "express"),
535 ("router.put", "express"),
536 ("router.delete", "express"),
537 ("router.patch", "express"),
538];
539
540pub fn detect_endpoints_from_references(
546 references: &[Reference],
547 namespace: &str,
548) -> Vec<DetectedEndpoint> {
549 let mut endpoints = Vec::new();
550
551 for r in references {
552 if r.kind != ReferenceKind::Call {
553 continue;
554 }
555
556 for &(pattern, _framework) in ROUTE_REGISTRATION_PATTERNS {
557 if r.target_name == pattern
558 || r.target_name.ends_with(&format!(
559 ".{}",
560 pattern.split('.').next_back().unwrap_or("")
561 ))
562 {
563 let method = extract_method_from_call_target(&r.target_name);
564 let handler_path = format!("handler:{}", r.source_qualified_name);
568 endpoints.push(DetectedEndpoint {
569 id: format!(
570 "ep:{namespace}:{}:{handler_path}",
571 method.as_deref().unwrap_or("ANY")
572 ),
573 method,
574 path: handler_path,
575 handler: r.source_qualified_name.clone(),
576 file_path: r.file_path.clone(),
577 line: r.line,
578 });
579 break;
580 }
581 }
582 }
583
584 endpoints
585}
586
587fn extract_method_from_call_target(target: &str) -> Option<String> {
589 let last_segment = target.rsplit('.').next().unwrap_or(target);
590 match last_segment {
591 "GET" | "get" => Some("GET".to_string()),
592 "POST" | "post" => Some("POST".to_string()),
593 "PUT" | "put" => Some("PUT".to_string()),
594 "DELETE" | "delete" => Some("DELETE".to_string()),
595 "PATCH" | "patch" => Some("PATCH".to_string()),
596 _ => None,
598 }
599}
600
601const EVENT_PATTERNS: &[(&str, &str, &str)] = &[
605 ("producer.send", "kafka", "publish"),
607 ("producer.produce", "kafka", "publish"),
608 ("consumer.subscribe", "kafka", "subscribe"),
609 ("consumer.poll", "kafka", "subscribe"),
610 ("channel.basic_publish", "rabbitmq", "publish"),
612 ("channel.basic_consume", "rabbitmq", "subscribe"),
613 ("channel.publish", "rabbitmq", "publish"),
614 ("channel.consume", "rabbitmq", "subscribe"),
615 ("redis.publish", "redis", "publish"),
617 ("redis.subscribe", "redis", "subscribe"),
618 ("client.publish", "redis", "publish"),
619 ("client.subscribe", "redis", "subscribe"),
620 ("sqs.send_message", "sqs", "publish"),
622 ("sqs.receive_message", "sqs", "subscribe"),
623 ("sqs.sendMessage", "sqs", "publish"),
624 ("sqs.receiveMessage", "sqs", "subscribe"),
625 ("sns.publish", "sns", "publish"),
627 ("nc.publish", "nats", "publish"),
629 ("nc.subscribe", "nats", "subscribe"),
630 ("nats.publish", "nats", "publish"),
631 ("nats.subscribe", "nats", "subscribe"),
632 ("emitter.emit", "event", "publish"),
634 ("emitter.on", "event", "subscribe"),
635];
636
637const EVENT_ANNOTATION_PATTERNS: &[(&str, &str, &str)] = &[
639 ("KafkaListener", "kafka", "subscribe"),
640 ("RabbitListener", "rabbitmq", "subscribe"),
641 ("SqsListener", "sqs", "subscribe"),
642 ("JmsListener", "jms", "subscribe"),
643 ("EventListener", "event", "subscribe"),
644];
645
646pub fn detect_event_calls(references: &[Reference], symbols: &[Symbol]) -> Vec<DetectedEventCall> {
648 let mut events = Vec::new();
649
650 for r in references {
652 if r.kind != ReferenceKind::Call {
653 continue;
654 }
655
656 for &(pattern, protocol, direction) in EVENT_PATTERNS {
657 if r.target_name == pattern || r.target_name.ends_with(pattern) {
658 events.push(DetectedEventCall {
659 caller: r.source_qualified_name.clone(),
660 channel: None, direction: direction.to_string(),
662 protocol: protocol.to_string(),
663 file_path: r.file_path.clone(),
664 line: r.line,
665 });
666 break;
667 }
668 }
669 }
670
671 for sym in symbols {
673 for attr in &sym.attributes {
674 for &(annotation, protocol, direction) in EVENT_ANNOTATION_PATTERNS {
675 if attr.contains(annotation) {
676 events.push(DetectedEventCall {
677 caller: sym.qualified_name.clone(),
678 channel: extract_path_from_decorator(attr),
679 direction: direction.to_string(),
680 protocol: protocol.to_string(),
681 file_path: sym.file_path.clone(),
682 line: sym.line_start,
683 });
684 break;
685 }
686 }
687 }
688 }
689
690 events
691}
692
693pub fn match_event_channels(
698 producers: &[DetectedEventCall],
699 consumers: &[DetectedEventCall],
700) -> Vec<(String, String, String, String, f64)> {
701 let mut matches = Vec::new();
702
703 for producer in producers {
704 for consumer in consumers {
705 if producer.protocol != consumer.protocol {
706 continue;
707 }
708 match (&producer.channel, &consumer.channel) {
713 (Some(pc), Some(cc)) => {
714 let confidence = if pc == cc {
715 0.95
716 } else if channels_match_pattern(pc, cc) {
717 0.8
718 } else {
719 continue;
720 };
721 matches.push((
722 producer.caller.clone(),
723 consumer.caller.clone(),
724 pc.clone(),
725 producer.protocol.clone(),
726 confidence,
727 ));
728 }
729 _ => continue,
732 }
733 }
734 }
735
736 matches
737}
738
739fn channels_match_pattern(a: &str, b: &str) -> bool {
742 if a.contains('*') || a.contains('#') {
743 let prefix = a.trim_end_matches(['*', '#', '.']);
744 b.starts_with(prefix)
745 } else if b.contains('*') || b.contains('#') {
746 let prefix = b.trim_end_matches(['*', '#', '.']);
747 a.starts_with(prefix)
748 } else {
749 false
750 }
751}
752
753#[cfg(test)]
754#[path = "tests/api_surface_tests.rs"]
755mod tests;