orion-server 0.2.0

Declarative services runtime powered by dataflow-rs
use std::collections::HashMap;

use crate::storage::models::{CHANNEL_TYPE_SYNC, Channel, ChannelProtocol};

/// A single entry in the route table.
struct RouteEntry {
    /// Channel name (used as engine channel key).
    channel_name: String,
    /// Allowed HTTP methods (uppercase). Empty = any method.
    methods: Vec<String>,
    /// Route segments parsed from route_pattern. Each is Static("orders")
    /// or Param("id").
    segments: Vec<RouteSegment>,
    /// Channel priority (higher = checked first).
    priority: i64,
}

#[derive(Debug, Clone)]
enum RouteSegment {
    Static(String),
    Param(String),
}

/// Result of a successful route match.
#[derive(Debug, Clone)]
pub struct RouteMatch {
    /// The channel name that matched.
    pub channel_name: String,
    /// Extracted path parameters (e.g. {"id": "123"}).
    pub params: HashMap<String, String>,
}

/// Parse a route pattern like "/orders/{id}/items/{item_id}" into segments.
fn parse_route_pattern(pattern: &str) -> Vec<RouteSegment> {
    pattern
        .split('/')
        .filter(|s| !s.is_empty())
        .map(|seg| {
            if seg.starts_with('{') && seg.ends_with('}') {
                RouteSegment::Param(seg[1..seg.len() - 1].to_string())
            } else {
                RouteSegment::Static(seg.to_string())
            }
        })
        .collect()
}

/// Try to match a request path against a route pattern's segments.
/// Returns extracted params on success.
fn match_segments(
    segments: &[RouteSegment],
    path_parts: &[&str],
) -> Option<HashMap<String, String>> {
    if segments.len() != path_parts.len() {
        return None;
    }
    let param_count = segments
        .iter()
        .filter(|s| matches!(s, RouteSegment::Param(_)))
        .count();
    let mut params = HashMap::with_capacity(param_count);
    for (seg, part) in segments.iter().zip(path_parts.iter()) {
        match seg {
            RouteSegment::Static(expected) => {
                if !expected.eq_ignore_ascii_case(part) {
                    return None;
                }
            }
            RouteSegment::Param(name) => {
                params.insert(name.clone(), (*part).to_string());
            }
        }
    }
    Some(params)
}

/// Route table built from active REST channels, sorted by priority.
pub struct RouteTable {
    entries: Vec<RouteEntry>,
}

impl RouteTable {
    pub(super) fn new() -> Self {
        Self {
            entries: Vec::new(),
        }
    }

    pub(super) fn build(channels: &[Channel]) -> Self {
        let mut entries: Vec<RouteEntry> = channels
            .iter()
            .filter(|ch| {
                ch.channel_type == CHANNEL_TYPE_SYNC
                    && (ch.protocol == ChannelProtocol::Rest.as_str()
                        || ch.protocol == ChannelProtocol::Http.as_str())
                    && ch.route_pattern.is_some()
            })
            .filter_map(|ch| {
                let pattern = ch.route_pattern.as_deref()?;
                let segments = parse_route_pattern(pattern);
                let methods: Vec<String> = ch
                    .methods
                    .as_deref()
                    .and_then(|m| serde_json::from_str::<Vec<String>>(m).ok())
                    .unwrap_or_default()
                    .into_iter()
                    .map(|m| m.to_uppercase())
                    .collect();
                Some(RouteEntry {
                    channel_name: ch.name.clone(),
                    methods,
                    segments,
                    priority: ch.priority,
                })
            })
            .collect();

        // Sort by priority descending, then by segment count descending
        // (more specific routes first)
        entries.sort_by(|a, b| {
            b.priority
                .cmp(&a.priority)
                .then_with(|| b.segments.len().cmp(&a.segments.len()))
        });

        Self { entries }
    }

    /// Match a request (method, path) against the route table.
    /// Path should NOT include the `/api/v1/data/` prefix.
    pub fn match_route(&self, method: &str, path: &str) -> Option<RouteMatch> {
        let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

        for entry in &self.entries {
            // Check method match (empty methods = accept any)
            if !entry.methods.is_empty()
                && !entry.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
            {
                continue;
            }
            if let Some(params) = match_segments(&entry.segments, &path_parts) {
                return Some(RouteMatch {
                    channel_name: entry.channel_name.clone(),
                    params,
                });
            }
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_route_pattern_simple() {
        let segments = parse_route_pattern("/orders");
        assert_eq!(segments.len(), 1);
        assert!(matches!(&segments[0], RouteSegment::Static(s) if s == "orders"));
    }

    #[test]
    fn test_parse_route_pattern_with_params() {
        let segments = parse_route_pattern("/orders/{id}/items/{item_id}");
        assert_eq!(segments.len(), 4);
        assert!(matches!(&segments[0], RouteSegment::Static(s) if s == "orders"));
        assert!(matches!(&segments[1], RouteSegment::Param(s) if s == "id"));
        assert!(matches!(&segments[2], RouteSegment::Static(s) if s == "items"));
        assert!(matches!(&segments[3], RouteSegment::Param(s) if s == "item_id"));
    }

    #[test]
    fn test_match_segments_exact() {
        let segments = parse_route_pattern("/orders/{id}");
        let params = match_segments(&segments, &["orders", "123"]);
        assert!(params.is_some());
        assert_eq!(params.expect("test").get("id").expect("test"), "123");
    }

    #[test]
    fn test_match_segments_no_match() {
        let segments = parse_route_pattern("/orders/{id}");
        assert!(match_segments(&segments, &["users", "123"]).is_none());
        assert!(match_segments(&segments, &["orders"]).is_none());
        assert!(match_segments(&segments, &["orders", "123", "items"]).is_none());
    }

    #[test]
    fn test_route_table_match() {
        let table = RouteTable {
            entries: vec![RouteEntry {
                channel_name: "orders.get".to_string(),
                methods: vec!["GET".to_string()],
                segments: parse_route_pattern("/orders/{id}"),
                priority: 0,
            }],
        };
        let result = table.match_route("GET", "orders/42");
        assert!(result.is_some());
        let rm = result.expect("test");
        assert_eq!(rm.channel_name, "orders.get");
        assert_eq!(rm.params.get("id").expect("test"), "42");
    }

    #[test]
    fn test_route_table_method_mismatch() {
        let table = RouteTable {
            entries: vec![RouteEntry {
                channel_name: "orders.get".to_string(),
                methods: vec!["GET".to_string()],
                segments: parse_route_pattern("/orders/{id}"),
                priority: 0,
            }],
        };
        assert!(table.match_route("POST", "orders/42").is_none());
    }

    #[test]
    fn test_route_table_priority_ordering() {
        let table = RouteTable {
            entries: vec![
                RouteEntry {
                    channel_name: "low".to_string(),
                    methods: vec![],
                    segments: parse_route_pattern("/items/{id}"),
                    priority: 0,
                },
                RouteEntry {
                    channel_name: "high".to_string(),
                    methods: vec![],
                    segments: parse_route_pattern("/items/{id}"),
                    priority: 10,
                },
            ],
        };
        // After sorting by priority desc, "high" should be first
        // But since we build the entries manually without sorting here,
        // let's test via RouteTable::build instead
        assert_eq!(
            table
                .match_route("GET", "items/1")
                .expect("test")
                .channel_name,
            "low"
        );
    }
}