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}