use std::sync::{OnceLock, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OpenApiResponseDesc {
pub status: u16,
pub description: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OpenApiRouteSpec {
pub summary: Option<&'static str>,
pub tag: Option<&'static str>,
pub responses: &'static [OpenApiResponseDesc],
pub request_body_schema: Option<&'static str>,
pub response_schema: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RouteInfo {
pub method: &'static str,
pub path: &'static str,
pub handler: &'static str,
pub openapi: Option<&'static OpenApiRouteSpec>,
}
fn store() -> &'static RwLock<Vec<RouteInfo>> {
static STORE: OnceLock<RwLock<Vec<RouteInfo>>> = OnceLock::new();
STORE.get_or_init(|| RwLock::new(Vec::new()))
}
pub struct RouteRegistry;
impl RouteRegistry {
pub fn register(method: &'static str, path: &'static str, handler: &'static str) {
Self::register_spec(method, path, handler, None);
}
pub fn register_spec(
method: &'static str,
path: &'static str,
handler: &'static str,
openapi: Option<&'static OpenApiRouteSpec>,
) {
let mut guard = store().write().expect("route registry lock poisoned");
guard.push(RouteInfo {
method,
path,
handler,
openapi,
});
}
pub fn list() -> Vec<RouteInfo> {
let guard = store().read().expect("route registry lock poisoned");
guard.clone()
}
pub fn handler_for(method: &str, path: &str) -> Option<String> {
let guard = store().read().expect("route registry lock poisoned");
guard
.iter()
.find(|r| r.method.eq_ignore_ascii_case(method) && r.path == path)
.map(|r| r.handler.to_string())
}
pub fn conflict_report() -> RouteConflictReport {
let routes = Self::list();
let mut duplicates = Vec::new();
let mut shadows = Vec::new();
let mut seen: std::collections::HashMap<(String, String), &'static str> =
std::collections::HashMap::new();
for r in &routes {
let key = (r.method.to_ascii_uppercase(), r.path.to_string());
if let Some(prev) = seen.insert(key.clone(), r.handler) {
duplicates.push(RouteConflict {
method: r.method,
path: r.path,
first_handler: prev,
second_handler: r.handler,
kind: RouteConflictKind::Duplicate,
});
}
}
for (i, earlier) in routes.iter().enumerate() {
for later in routes.iter().skip(i + 1) {
if !earlier.method.eq_ignore_ascii_case(later.method) {
continue;
}
if paths_shadow(earlier.path, later.path) {
shadows.push(RouteConflict {
method: later.method,
path: later.path,
first_handler: earlier.handler,
second_handler: later.handler,
kind: RouteConflictKind::Shadow,
});
}
}
}
RouteConflictReport {
duplicates,
shadows,
}
}
#[cfg(feature = "test-hooks")]
pub fn clear_for_tests() {
let mut guard = store().write().expect("route registry lock poisoned");
guard.clear();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteConflictKind {
Duplicate,
Shadow,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteConflict {
pub method: &'static str,
pub path: &'static str,
pub first_handler: &'static str,
pub second_handler: &'static str,
pub kind: RouteConflictKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteConflictReport {
pub duplicates: Vec<RouteConflict>,
pub shadows: Vec<RouteConflict>,
}
impl RouteConflictReport {
pub fn is_empty(&self) -> bool {
self.duplicates.is_empty() && self.shadows.is_empty()
}
}
fn paths_shadow(earlier: &str, later: &str) -> bool {
let a: Vec<&str> = earlier.split('/').filter(|s| !s.is_empty()).collect();
let b: Vec<&str> = later.split('/').filter(|s| !s.is_empty()).collect();
if a.len() != b.len() {
return false;
}
let mut earlier_has_param = false;
let mut later_is_more_specific = false;
for (ea, lb) in a.iter().zip(b.iter()) {
let ea_param = ea.starts_with(':') || (ea.starts_with('{') && ea.ends_with('}'));
let lb_param = lb.starts_with(':') || (lb.starts_with('{') && lb.ends_with('}'));
if ea_param {
earlier_has_param = true;
if !lb_param && *ea != *lb {
later_is_more_specific = true;
}
} else if lb_param || *ea != *lb {
return false;
}
}
earlier_has_param && later_is_more_specific
}