Skip to main content

truto/
query.rs

1//! Query parameters and their `qs`-compatible encoding.
2
3use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
4use serde_json::{Map, Number, Value};
5
6use crate::error::Error;
7
8/// Everything except the RFC 3986 unreserved set (`A-Z a-z 0-9 - _ . ~`).
9pub(crate) const STRICT: &AsciiSet = &NON_ALPHANUMERIC
10    .remove(b'-')
11    .remove(b'_')
12    .remove(b'.')
13    .remove(b'~');
14
15/// Query parameters for a request.
16///
17/// Values can be any JSON: nested objects and arrays are encoded the way the
18/// `qs` package does (`filter[status]=open`, `ids[0]=a`), which is what the
19/// Truto API expects. Anything that converts into a JSON object also works
20/// wherever a query is accepted, e.g. `serde_json::json!({"limit": 10})`.
21///
22/// ```
23/// use truto::Query;
24/// let q = Query::new().set("integrated_account_id", "ia_1").set("limit", 50);
25/// assert_eq!(q.encode(), "integrated_account_id=ia_1&limit=50");
26/// ```
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct Query(Map<String, Value>);
29
30impl Query {
31    /// An empty query.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Set `key` to `value`, builder style.
37    pub fn set(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
38        self.0.insert(key.into(), value.into());
39        self
40    }
41
42    /// Set `key` to `value` in place.
43    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<Value>) -> &mut Self {
44        self.0.insert(key.into(), value.into());
45        self
46    }
47
48    /// The value for `key`.
49    pub fn get(&self, key: &str) -> Option<&Value> {
50        self.0.get(key)
51    }
52
53    /// Remove `key`, returning its value.
54    pub fn remove(&mut self, key: &str) -> Option<Value> {
55        self.0.remove(key)
56    }
57
58    /// `true` when there are no parameters.
59    pub fn is_empty(&self) -> bool {
60        self.0.is_empty()
61    }
62
63    /// The parameters as a JSON object.
64    pub fn as_map(&self) -> &Map<String, Value> {
65        &self.0
66    }
67
68    /// Consume the query and return the parameters as a JSON object.
69    pub fn into_map(self) -> Map<String, Value> {
70        self.0
71    }
72
73    /// Encode as a query string (without the leading `?`).
74    pub fn encode(&self) -> String {
75        encode(&self.0)
76    }
77
78    pub(crate) fn merge(&mut self, other: Map<String, Value>) {
79        for (k, v) in other {
80            self.0.insert(k, v);
81        }
82    }
83}
84
85impl From<Map<String, Value>> for Query {
86    fn from(map: Map<String, Value>) -> Self {
87        Self(map)
88    }
89}
90
91/// Types accepted as query parameters: [`Query`], a JSON object
92/// ([`serde_json::Value`] or [`serde_json::Map`]), or references to them.
93///
94/// A `Value` that is not an object (or `null`) is rejected when the request
95/// is sent.
96pub trait IntoQuery {
97    /// Convert into a JSON object of parameters.
98    fn into_query(self) -> Result<Map<String, Value>, Error>;
99}
100
101impl IntoQuery for Query {
102    fn into_query(self) -> Result<Map<String, Value>, Error> {
103        Ok(self.0)
104    }
105}
106
107impl IntoQuery for &Query {
108    fn into_query(self) -> Result<Map<String, Value>, Error> {
109        Ok(self.0.clone())
110    }
111}
112
113impl IntoQuery for Map<String, Value> {
114    fn into_query(self) -> Result<Map<String, Value>, Error> {
115        Ok(self)
116    }
117}
118
119impl IntoQuery for &Map<String, Value> {
120    fn into_query(self) -> Result<Map<String, Value>, Error> {
121        Ok(self.clone())
122    }
123}
124
125impl IntoQuery for Value {
126    fn into_query(self) -> Result<Map<String, Value>, Error> {
127        match self {
128            Value::Object(map) => Ok(map),
129            Value::Null => Ok(Map::new()),
130            other => Err(Error::InvalidRequest(format!(
131                "query must be a JSON object, got {other}"
132            ))),
133        }
134    }
135}
136
137impl IntoQuery for &Value {
138    fn into_query(self) -> Result<Map<String, Value>, Error> {
139        self.clone().into_query()
140    }
141}
142
143/// Encode a JSON object exactly like `qs.stringify(obj, { sort })`.
144pub fn encode(map: &Map<String, Value>) -> String {
145    let mut parts = Vec::new();
146    let mut keys: Vec<&String> = map.keys().collect();
147    keys.sort();
148    for key in keys {
149        encode_value(key, &map[key.as_str()], &mut parts);
150    }
151    parts.join("&")
152}
153
154fn encode_value(prefix: &str, value: &Value, parts: &mut Vec<String>) {
155    match value {
156        Value::Object(obj) => {
157            let mut keys: Vec<&String> = obj.keys().collect();
158            keys.sort();
159            for key in keys {
160                encode_value(&format!("{prefix}[{key}]"), &obj[key.as_str()], parts);
161            }
162        }
163        Value::Array(items) => {
164            for (i, item) in items.iter().enumerate() {
165                encode_value(&format!("{prefix}[{i}]"), item, parts);
166            }
167        }
168        Value::Null => parts.push(format!("{}=", escape(prefix))),
169        Value::Bool(b) => parts.push(format!("{}={b}", escape(prefix))),
170        Value::Number(n) => parts.push(format!("{}={}", escape(prefix), number(n))),
171        Value::String(s) => parts.push(format!("{}={}", escape(prefix), escape(s))),
172    }
173}
174
175/// Format a number the way JavaScript's `String(n)` does for common values.
176fn number(n: &Number) -> String {
177    if n.is_i64() || n.is_u64() {
178        return n.to_string();
179    }
180    match n.as_f64() {
181        Some(f) if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 => format!("{f:.0}"),
182        _ => n.to_string(),
183    }
184}
185
186fn escape(s: &str) -> String {
187    utf8_percent_encode(s, STRICT).to_string()
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use serde_json::json;
194
195    #[test]
196    fn whole_floats_drop_the_fraction() {
197        let q = json!({"a": 50.0, "b": 1.25});
198        assert_eq!(encode(q.as_object().unwrap()), "a=50&b=1.25");
199    }
200
201    #[test]
202    fn builder_merges() {
203        let mut q = Query::new().set("a", 1);
204        q.merge(json!({"b": "x"}).into_query().unwrap());
205        assert_eq!(q.encode(), "a=1&b=x");
206    }
207}