Skip to main content

autumn_web/
paths.rs

1//! Typed path helpers and the [`PathExt`] fluent query-string builder.
2//!
3//! Route macros emit a `__autumn_path_{name}(…) -> String` companion alongside
4//! every handler. This module provides the [`PathExt`] extension trait that
5//! lets callers append query parameters to those strings with a single
6//! chained expression:
7//!
8//! ```ignore
9//! let url = paths::list_posts().with_query("page", 2).with_query("size", 10);
10//! // → "/posts?page=2&size=10"
11//! ```
12
13/// Fluent query-string builder for path strings produced by typed path helpers.
14///
15/// Automatically imported via [`autumn_web::prelude`].
16pub trait PathExt {
17    /// Append a percent-encoded `key=value` query parameter.
18    ///
19    /// The first call adds `?key=value`; subsequent calls add `&key=value`.
20    /// Both key and value are percent-encoded (RFC 3986 §2.1).
21    ///
22    /// # Examples
23    ///
24    /// ```
25    /// use autumn_web::paths::PathExt;
26    ///
27    /// let url = "/posts".to_string().with_query("page", 2).with_query("q", "hello world");
28    /// assert_eq!(url, "/posts?page=2&q=hello%20world");
29    /// ```
30    #[must_use]
31    fn with_query(self, key: impl std::fmt::Display, value: impl std::fmt::Display) -> String;
32}
33
34impl PathExt for String {
35    fn with_query(self, key: impl std::fmt::Display, value: impl std::fmt::Display) -> String {
36        let encoded_key = percent_encode(&key.to_string());
37        let encoded_value = percent_encode(&value.to_string());
38        let sep = if self.contains('?') { '&' } else { '?' };
39        format!("{self}{sep}{encoded_key}={encoded_value}")
40    }
41}
42
43/// Percent-encode one dynamic route path segment.
44///
45/// Route macro helpers use this before interpolating path parameters so
46/// Display values like `a/b` remain a single segment (`a%2Fb`).
47#[doc(hidden)]
48#[must_use]
49pub fn encode_path_segment(value: impl std::fmt::Display) -> String {
50    percent_encode(&value.to_string())
51}
52
53/// Percent-encode a query component per RFC 3986.
54///
55/// Unreserved characters (ALPHA / DIGIT / `-` / `_` / `.` / `~`) are left
56/// unchanged; everything else is `%XX`-encoded.
57fn percent_encode(s: &str) -> String {
58    let mut out = String::with_capacity(s.len());
59    for byte in s.bytes() {
60        match byte {
61            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
62                out.push(byte as char);
63            }
64            b => {
65                out.push('%');
66                let hi = b >> 4;
67                let lo = b & 0xF;
68                out.push(
69                    char::from_digit(u32::from(hi), 16)
70                        .unwrap()
71                        .to_ascii_uppercase(),
72                );
73                out.push(
74                    char::from_digit(u32::from(lo), 16)
75                        .unwrap()
76                        .to_ascii_uppercase(),
77                );
78            }
79        }
80    }
81    out
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn with_query_first_param() {
90        assert_eq!("/posts".to_string().with_query("page", 1), "/posts?page=1");
91    }
92
93    #[test]
94    fn with_query_second_param() {
95        let url = "/posts"
96            .to_string()
97            .with_query("page", 1)
98            .with_query("size", 20);
99        assert_eq!(url, "/posts?page=1&size=20");
100    }
101
102    #[test]
103    fn with_query_encodes_space() {
104        assert_eq!(
105            "/search".to_string().with_query("q", "hello world"),
106            "/search?q=hello%20world"
107        );
108    }
109
110    #[test]
111    fn with_query_encodes_equals_and_ampersand() {
112        assert_eq!(
113            "/x".to_string().with_query("filter", "a=b&c"),
114            "/x?filter=a%3Db%26c"
115        );
116    }
117
118    #[test]
119    fn with_query_leaves_unreserved_chars_alone() {
120        assert_eq!(
121            "/x".to_string()
122                .with_query("tag", "hello-world_foo.bar~baz"),
123            "/x?tag=hello-world_foo.bar~baz"
124        );
125    }
126}