rok-core 0.6.0

Core primitives for the rok ecosystem — errors, crypto, i18n, config, DI, and more
Documentation
use dashmap::DashMap;
use std::sync::OnceLock;

static ROUTES: OnceLock<DashMap<String, String>> = OnceLock::new();

fn registry() -> &'static DashMap<String, String> {
    ROUTES.get_or_init(DashMap::new)
}

/// Register a named route pattern.
///
/// Path parameters use `:name` syntax (e.g. `/posts/:id`).
/// Call this once at startup before handling any requests.
pub fn register(name: impl Into<String>, pattern: impl Into<String>) {
    registry().insert(name.into(), pattern.into());
}

/// Resolve a named route, substituting path parameters and appending query params.
///
/// Parameters that match a `:segment` in the pattern are substituted inline.
/// Remaining parameters are appended as query string entries.
///
/// # Panics
///
/// Panics if `name` has not been registered.
pub fn resolve(name: &str, params: &[(&str, &str)]) -> String {
    let pattern = registry()
        .get(name)
        .map(|r| r.clone())
        .unwrap_or_else(|| panic!("rok-core: no named route registered for '{name}'"));

    let mut url = pattern;
    let mut query: Vec<(&str, &str)> = Vec::new();

    for &(key, val) in params {
        let placeholder = format!(":{key}");
        if url.contains(placeholder.as_str()) {
            url = url.replace(placeholder.as_str(), val);
        } else {
            query.push((key, val));
        }
    }

    if !query.is_empty() {
        let qs: String = query
            .iter()
            .map(|(k, v)| format!("{k}={v}"))
            .collect::<Vec<_>>()
            .join("&");
        url = format!("{url}?{qs}");
    }

    url
}

/// Generate a URL for a named route.
///
/// # Examples
///
/// ```rust,ignore
/// let url = route!("posts.show", id = 42);          // "/posts/42"
/// let url = route!("users.index");                   // "/users"
/// let url = route!("posts.index", page = 2);         // "/posts?page=2"
/// ```
#[macro_export]
macro_rules! route {
    ($name:expr) => {
        $crate::named_routes::resolve($name, &[])
    };
    ($name:expr, $($key:ident = $val:expr),+ $(,)?) => {
        $crate::named_routes::resolve($name, &[$( (stringify!($key), &$val.to_string()) ),+])
    };
}

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

    fn setup() {
        register("posts.show", "/posts/:id");
        register("users.index", "/users");
        register("posts.index", "/posts");
    }

    #[test]
    fn resolve_path_param() {
        setup();
        assert_eq!(resolve("posts.show", &[("id", "42")]), "/posts/42");
    }

    #[test]
    fn resolve_no_params() {
        setup();
        assert_eq!(resolve("users.index", &[]), "/users");
    }

    #[test]
    fn resolve_query_param() {
        setup();
        assert_eq!(resolve("posts.index", &[("page", "2")]), "/posts?page=2");
    }

    #[test]
    fn resolve_mixed_params() {
        register("posts.comments", "/posts/:post_id/comments/:id");
        let url = resolve("posts.comments", &[("post_id", "1"), ("id", "5")]);
        assert_eq!(url, "/posts/1/comments/5");
    }
}