query_string_builder/owned.rs
1//! The owning, allocation-friendly query string builder.
2
3use crate::QUERY;
4use percent_encoding::utf8_percent_encode;
5use std::fmt::{self, Display, Formatter, Write};
6
7/// An owning query string builder for percent encoding key-value pairs.
8///
9/// This is the lifetime-free twin of [`QueryString`](crate::QueryString): it
10/// eagerly converts keys and values to owned [`String`]s, so it can be stored
11/// in structs, returned from functions and passed around freely.
12///
13/// ## Example
14///
15/// ```
16/// use query_string_builder::QueryStringOwned;
17///
18/// let qs = QueryStringOwned::new()
19/// .with("q", "apple")
20/// .with("tasty", true)
21/// .with_opt("category", Some("fruits and vegetables"));
22///
23/// assert_eq!(
24/// format!("https://example.com/{qs}"),
25/// "https://example.com/?q=apple&tasty=true&category=fruits%20and%20vegetables"
26/// );
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct QueryStringOwned {
30 pairs: Vec<(String, String)>,
31}
32
33impl QueryStringOwned {
34 /// Creates a new, empty query string builder.
35 pub fn new() -> Self {
36 Self { pairs: Vec::new() }
37 }
38
39 pub(crate) fn from_pairs(pairs: Vec<(String, String)>) -> Self {
40 Self { pairs }
41 }
42
43 /// Appends a key-value pair to the query string.
44 ///
45 /// ## Example
46 ///
47 /// ```
48 /// use query_string_builder::QueryStringOwned;
49 ///
50 /// let qs = QueryStringOwned::new()
51 /// .with("q", "🍎 apple")
52 /// .with("category", "fruits and vegetables")
53 /// .with("answer", 42);
54 ///
55 /// assert_eq!(
56 /// format!("https://example.com/{qs}"),
57 /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&answer=42"
58 /// );
59 /// ```
60 #[doc(alias = "with_value")]
61 pub fn with<K: ToString, V: ToString>(mut self, key: K, value: V) -> Self {
62 self.pairs.push((key.to_string(), value.to_string()));
63 self
64 }
65
66 /// Appends a key-value pair to the query string if the value exists.
67 ///
68 /// ## Example
69 ///
70 /// ```
71 /// use query_string_builder::QueryStringOwned;
72 ///
73 /// let qs = QueryStringOwned::new()
74 /// .with_opt("q", Some("🍎 apple"))
75 /// .with_opt("f", None::<String>)
76 /// .with_opt("category", Some("fruits and vegetables"))
77 /// .with_opt("works", Some(true));
78 ///
79 /// assert_eq!(
80 /// format!("https://example.com/{qs}"),
81 /// "https://example.com/?q=%F0%9F%8D%8E%20apple&category=fruits%20and%20vegetables&works=true"
82 /// );
83 /// ```
84 #[doc(alias = "with_opt_value")]
85 pub fn with_opt<K: ToString, V: ToString>(self, key: K, value: Option<V>) -> Self {
86 if let Some(value) = value {
87 self.with(key, value)
88 } else {
89 self
90 }
91 }
92
93 /// Appends a key-value pair to the query string.
94 ///
95 /// ## Example
96 ///
97 /// ```
98 /// use query_string_builder::QueryStringOwned;
99 ///
100 /// let mut qs = QueryStringOwned::new();
101 /// qs.push("q", "apple");
102 /// qs.push("category", "fruits and vegetables");
103 ///
104 /// assert_eq!(
105 /// format!("https://example.com/{qs}"),
106 /// "https://example.com/?q=apple&category=fruits%20and%20vegetables"
107 /// );
108 /// ```
109 pub fn push<K: ToString, V: ToString>(&mut self, key: K, value: V) -> &mut Self {
110 self.pairs.push((key.to_string(), value.to_string()));
111 self
112 }
113
114 /// Appends a key-value pair to the query string if the value exists.
115 ///
116 /// ## Example
117 ///
118 /// ```
119 /// use query_string_builder::QueryStringOwned;
120 ///
121 /// let mut qs = QueryStringOwned::new();
122 /// qs.push_opt("q", None::<String>);
123 /// qs.push_opt("q", Some("🍎 apple"));
124 ///
125 /// assert_eq!(
126 /// format!("https://example.com/{qs}"),
127 /// "https://example.com/?q=%F0%9F%8D%8E%20apple"
128 /// );
129 /// ```
130 pub fn push_opt<K: ToString, V: ToString>(&mut self, key: K, value: Option<V>) -> &mut Self {
131 if let Some(value) = value {
132 self.push(key, value)
133 } else {
134 self
135 }
136 }
137
138 /// Determines the number of key-value pairs currently in the builder.
139 pub fn len(&self) -> usize {
140 self.pairs.len()
141 }
142
143 /// Determines if the builder is currently empty.
144 pub fn is_empty(&self) -> bool {
145 self.pairs.is_empty()
146 }
147
148 /// Appends another query string builder's values.
149 ///
150 /// ## Example
151 ///
152 /// ```
153 /// use query_string_builder::QueryStringOwned;
154 ///
155 /// let mut qs = QueryStringOwned::new().with("q", "apple");
156 /// let more = QueryStringOwned::new().with("q", "pear");
157 ///
158 /// qs.append(more);
159 ///
160 /// assert_eq!(
161 /// format!("https://example.com/{qs}"),
162 /// "https://example.com/?q=apple&q=pear"
163 /// );
164 /// ```
165 pub fn append(&mut self, mut other: QueryStringOwned) {
166 self.pairs.append(&mut other.pairs)
167 }
168
169 /// Appends another query string builder's values, consuming both types.
170 ///
171 /// ## Example
172 ///
173 /// ```
174 /// use query_string_builder::QueryStringOwned;
175 ///
176 /// let qs = QueryStringOwned::new().with("q", "apple");
177 /// let more = QueryStringOwned::new().with("q", "pear");
178 ///
179 /// let qs = qs.append_into(more);
180 ///
181 /// assert_eq!(
182 /// format!("https://example.com/{qs}"),
183 /// "https://example.com/?q=apple&q=pear"
184 /// );
185 /// ```
186 pub fn append_into(mut self, mut other: QueryStringOwned) -> Self {
187 self.pairs.append(&mut other.pairs);
188 self
189 }
190}
191
192impl Display for QueryStringOwned {
193 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
194 if self.pairs.is_empty() {
195 return Ok(());
196 }
197 f.write_char('?')?;
198 for (i, (key, value)) in self.pairs.iter().enumerate() {
199 if i > 0 {
200 f.write_char('&')?;
201 }
202 Display::fmt(&utf8_percent_encode(key, QUERY), f)?;
203 f.write_char('=')?;
204 Display::fmt(&utf8_percent_encode(value, QUERY), f)?;
205 }
206 Ok(())
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_empty() {
216 let qs = QueryStringOwned::new();
217 assert_eq!(qs.to_string(), "");
218 assert_eq!(qs.len(), 0);
219 assert!(qs.is_empty());
220 }
221
222 #[test]
223 fn test_simple() {
224 let qs = QueryStringOwned::new()
225 .with("q", "apple???")
226 .with("category", "fruits and vegetables")
227 .with("tasty", true)
228 .with("weight", 99.9);
229 assert_eq!(
230 qs.to_string(),
231 "?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
232 );
233 assert_eq!(qs.len(), 4);
234 assert!(!qs.is_empty());
235 }
236
237 #[test]
238 fn test_encoding() {
239 let qs = QueryStringOwned::new()
240 .with("q", "Grünkohl")
241 .with("category", "Gemüse");
242 assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
243 }
244
245 #[test]
246 fn test_emoji() {
247 let qs = QueryStringOwned::new().with("q", "🥦").with("🍽️", "🍔🍕");
248 assert_eq!(
249 qs.to_string(),
250 "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
251 );
252 }
253
254 #[test]
255 fn test_optional() {
256 let qs = QueryStringOwned::new()
257 .with("q", "celery")
258 .with_opt("taste", None::<String>)
259 .with_opt("category", Some("fruits and vegetables"))
260 .with_opt("tasty", Some(true))
261 .with_opt("weight", Some(99.9));
262 assert_eq!(
263 qs.to_string(),
264 "?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
265 );
266 assert_eq!(qs.len(), 4); // not five!
267 }
268
269 #[test]
270 fn test_push_optional() {
271 let mut qs = QueryStringOwned::new();
272 qs.push("a", "apple");
273 qs.push_opt("b", None::<String>);
274 qs.push_opt("c", Some("🍎 apple"));
275
276 assert_eq!(
277 format!("https://example.com/{qs}"),
278 "https://example.com/?a=apple&c=%F0%9F%8D%8E%20apple"
279 );
280 }
281
282 #[test]
283 fn test_append() {
284 let qs = QueryStringOwned::new().with("q", "apple");
285 let more = QueryStringOwned::new().with("q", "pear");
286
287 let mut qs = qs.append_into(more);
288 qs.append(QueryStringOwned::new().with("answer", "42"));
289
290 assert_eq!(
291 format!("https://example.com/{qs}"),
292 "https://example.com/?q=apple&q=pear&answer=42"
293 );
294 }
295
296 #[test]
297 fn test_characters() {
298 let tests = vec![
299 ("space", " ", "%20"),
300 ("double_quote", "\"", "%22"),
301 ("hash", "#", "%23"),
302 ("less_than", "<", "%3C"),
303 ("equals", "=", "%3D"),
304 ("greater_than", ">", "%3E"),
305 ("percent", "%", "%25"),
306 ("ampersand", "&", "%26"),
307 ("plus", "+", "%2B"),
308 //
309 ("dollar", "$", "$"),
310 ("single_quote", "'", "'"),
311 ("comma", ",", ","),
312 ("forward_slash", "/", "/"),
313 ("colon", ":", ":"),
314 ("semicolon", ";", ";"),
315 ("question_mark", "?", "?"),
316 ("at", "@", "@"),
317 ("left_bracket", "[", "["),
318 ("backslash", "\\", "\\"),
319 ("right_bracket", "]", "]"),
320 ("caret", "^", "^"),
321 ("underscore", "_", "_"),
322 ("grave", "^", "^"),
323 ("left_curly", "{", "{"),
324 ("pipe", "|", "|"),
325 ("right_curly", "}", "}"),
326 ];
327
328 let mut qs = QueryStringOwned::new();
329 for (key, value, _) in &tests {
330 qs.push(key, value);
331 }
332
333 let mut expected = String::new();
334 for (i, (key, _, value)) in tests.iter().enumerate() {
335 if i > 0 {
336 expected.push('&');
337 }
338 expected.push_str(&format!("{key}={value}"));
339 }
340
341 assert_eq!(
342 format!("https://example.com/{qs}"),
343 format!("https://example.com/?{expected}")
344 );
345 }
346}