Skip to main content

rws_server/router/
names.rs

1//! Named routes and URL building (18.35.0, #94): `.name("post")` after a
2//! route, then [`url_for`]`("post", &[("id", "7")])` → `/posts/7`.
3//!
4//! Names are process-wide, like the route table an app builds once at
5//! start: registering a name again replaces it (the last app built wins).
6
7use std::collections::HashMap;
8use std::sync::{OnceLock, RwLock};
9
10use super::matcher::{parse_pattern, Segment};
11
12fn registry() -> &'static RwLock<HashMap<String, String>> {
13    static NAMES: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
14    NAMES.get_or_init(|| RwLock::new(HashMap::new()))
15}
16
17/// Give the route `pattern` the name `name`, for [`url_for`]. The app
18/// builders call it from their `.name(…)`; call it directly for routes
19/// registered some other way.
20pub fn name_route(name: &str, pattern: &str) {
21    if let Ok(mut names) = registry().write() {
22        names.insert(name.to_string(), pattern.to_string());
23    }
24}
25
26/// The pattern registered under `name`, as it was given (`/posts/{id}`).
27pub fn named_pattern(name: &str) -> Option<String> {
28    registry().read().ok()?.get(name).cloned()
29}
30
31/// Every named route as `(name, pattern)`, sorted by name.
32pub fn named_routes() -> Vec<(String, String)> {
33    let mut all: Vec<(String, String)> = match registry().read() {
34        Ok(names) => names.iter().map(|(n, p)| (n.clone(), p.clone())).collect(),
35        Err(_) => Vec::new(),
36    };
37    all.sort();
38    all
39}
40
41/// The path of the route named `name` with its parameters filled in from
42/// `params`; parameters the pattern doesn't have become the query string,
43/// in the order given. Values are percent-encoded (a wildcard keeps its
44/// `/`s). `None` when no route has that name or a parameter of the
45/// pattern is missing.
46///
47/// ```rust
48/// # extern crate rws_server as rust_web_server;
49/// use rust_web_server::router::{name_route, url_for};
50///
51/// name_route("doc_post", "/posts/{id}/comments");
52/// assert_eq!(Some("/posts/7/comments?page=2".to_string()), url_for("doc_post", &[("id", "7"), ("page", "2")]));
53/// assert_eq!(None, url_for("doc_post", &[]));
54/// ```
55pub fn url_for(name: &str, params: &[(&str, &str)]) -> Option<String> {
56    build_path(&named_pattern(name)?, params)
57}
58
59/// [`url_for`] on a pattern rather than a name.
60pub fn build_path(pattern: &str, params: &[(&str, &str)]) -> Option<String> {
61    let segments = parse_pattern(pattern);
62    let mut used = Vec::new();
63    let mut path = String::new();
64    for segment in &segments {
65        path.push('/');
66        match segment {
67            Segment::Literal(text) => path.push_str(text),
68            Segment::Param(name) => {
69                let value = params.iter().find(|(k, _)| k == name)?.1;
70                if value.is_empty() {
71                    return None;
72                }
73                path.push_str(&encode(value, false));
74                used.push(name.as_str());
75            }
76            Segment::Wildcard(name) => {
77                let value = params.iter().find(|(k, _)| k == name)?.1;
78                path.push_str(&encode(value.trim_start_matches('/'), true));
79                used.push(name.as_str());
80            }
81        }
82    }
83    if path.is_empty() {
84        path.push('/');
85    }
86    let query: Vec<String> = params
87        .iter()
88        .filter(|(k, _)| !used.contains(k))
89        .map(|(k, v)| format!("{}={}", encode(k, false), encode(v, false)))
90        .collect();
91    if !query.is_empty() {
92        path.push('?');
93        path.push_str(&query.join("&"));
94    }
95    Some(path)
96}
97
98/// Percent-encode everything but RFC 3986 unreserved characters (and `/`
99/// when `keep_slash`).
100pub(crate) fn encode(value: &str, keep_slash: bool) -> String {
101    let mut out = String::with_capacity(value.len());
102    for byte in value.bytes() {
103        let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') || (keep_slash && byte == b'/');
104        if unreserved {
105            out.push(byte as char);
106        } else {
107            out.push_str(&format!("%{byte:02X}"));
108        }
109    }
110    out
111}