#![deny(unsafe_code)]
mod slim;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use std::fmt::{Debug, Display, Formatter, Write};
pub use slim::{QueryStringSimple, WrappedQueryString};
pub(crate) const QUERY: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'%')
.add(b'&')
.add(b'=')
.add(b'+');
#[derive(Debug, Clone)]
pub struct QueryString {
pairs: Vec<Kvp>,
}
impl QueryString {
#[allow(clippy::new_ret_no_self)]
pub fn simple() -> QueryStringSimple {
QueryStringSimple::default()
}
pub fn dynamic() -> Self {
Self {
pairs: Vec::default(),
}
}
pub fn with_value<K: ToString, V: ToString>(mut self, key: K, value: V) -> Self {
self.pairs.push(Kvp {
key: key.to_string(),
value: value.to_string(),
});
self
}
pub fn with_opt_value<K: ToString, V: ToString>(self, key: K, value: Option<V>) -> Self {
if let Some(value) = value {
self.with_value(key, value)
} else {
self
}
}
pub fn push<K: ToString, V: ToString>(&mut self, key: K, value: V) -> &Self {
self.pairs.push(Kvp {
key: key.to_string(),
value: value.to_string(),
});
self
}
pub fn push_opt<K: ToString, V: ToString>(&mut self, key: K, value: Option<V>) -> &Self {
if let Some(value) = value {
self.push(key, value)
} else {
self
}
}
pub fn len(&self) -> usize {
self.pairs.len()
}
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
pub fn append(&mut self, mut other: QueryString) {
self.pairs.append(&mut other.pairs)
}
pub fn append_into(mut self, mut other: QueryString) -> Self {
self.pairs.append(&mut other.pairs);
self
}
}
impl Display for QueryString {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if self.pairs.is_empty() {
Ok(())
} else {
f.write_char('?')?;
for (i, pair) in self.pairs.iter().enumerate() {
if i > 0 {
f.write_char('&')?;
}
Display::fmt(&utf8_percent_encode(&pair.key, QUERY), f)?;
f.write_char('=')?;
Display::fmt(&utf8_percent_encode(&pair.value, QUERY), f)?;
}
Ok(())
}
}
}
#[derive(Debug, Clone)]
struct Kvp {
key: String,
value: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty() {
let qs = QueryStringSimple::default();
assert_eq!(qs.to_string(), "");
assert_eq!(qs.len(), 0);
assert!(qs.is_empty());
}
#[test]
fn test_simple() {
let qs = QueryString::dynamic()
.with_value("q", "apple???")
.with_value("category", "fruits and vegetables")
.with_value("tasty", true)
.with_value("weight", 99.9);
assert_eq!(
qs.to_string(),
"?q=apple???&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
);
assert_eq!(qs.len(), 4);
assert!(!qs.is_empty());
}
#[test]
fn test_encoding() {
let qs = QueryString::dynamic()
.with_value("q", "Grünkohl")
.with_value("category", "Gemüse");
assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
}
#[test]
fn test_emoji() {
let qs = QueryString::dynamic()
.with_value("q", "🥦")
.with_value("🍽️", "🍔🍕");
assert_eq!(
qs.to_string(),
"?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
);
}
#[test]
fn test_optional() {
let qs = QueryString::dynamic()
.with_value("q", "celery")
.with_opt_value("taste", None::<String>)
.with_opt_value("category", Some("fruits and vegetables"))
.with_opt_value("tasty", Some(true))
.with_opt_value("weight", Some(99.9));
assert_eq!(
qs.to_string(),
"?q=celery&category=fruits%20and%20vegetables&tasty=true&weight=99.9"
);
assert_eq!(qs.len(), 4); }
#[test]
fn test_push_optional() {
let mut qs = QueryString::dynamic();
qs.push("a", "apple");
qs.push_opt("b", None::<String>);
qs.push_opt("c", Some("🍎 apple"));
assert_eq!(
format!("https://example.com/{qs}"),
"https://example.com/?a=apple&c=%F0%9F%8D%8E%20apple"
);
}
#[test]
fn test_append() {
let qs = QueryString::dynamic().with_value("q", "apple");
let more = QueryString::dynamic().with_value("q", "pear");
let mut qs = qs.append_into(more);
qs.append(QueryString::dynamic().with_value("answer", "42"));
assert_eq!(
format!("https://example.com/{qs}"),
"https://example.com/?q=apple&q=pear&answer=42"
);
}
#[test]
fn test_characters() {
let tests = vec![
("space", " ", "%20"),
("double_quote", "\"", "%22"),
("hash", "#", "%23"),
("less_than", "<", "%3C"),
("equals", "=", "%3D"),
("greater_than", ">", "%3E"),
("percent", "%", "%25"),
("ampersand", "&", "%26"),
("plus", "+", "%2B"),
("dollar", "$", "$"),
("single_quote", "'", "'"),
("comma", ",", ","),
("forward_slash", "/", "/"),
("colon", ":", ":"),
("semicolon", ";", ";"),
("question_mark", "?", "?"),
("at", "@", "@"),
("left_bracket", "[", "["),
("backslash", "\\", "\\"),
("right_bracket", "]", "]"),
("caret", "^", "^"),
("underscore", "_", "_"),
("grave", "^", "^"),
("left_curly", "{", "{"),
("pipe", "|", "|"),
("right_curly", "}", "}"),
];
let mut qs = QueryString::dynamic();
for (key, value, _) in &tests {
qs.push(key.to_string(), value.to_string());
}
let mut expected = String::new();
for (i, (key, _, value)) in tests.iter().enumerate() {
if i > 0 {
expected.push('&');
}
expected.push_str(&format!("{key}={value}"));
}
assert_eq!(
format!("https://example.com/{qs}"),
format!("https://example.com/?{expected}")
);
}
}