Skip to main content

agent_uri/
query.rs

1//! Query parameters type for agent URIs.
2
3use std::cmp::Ordering;
4use std::collections::BTreeMap;
5use std::fmt;
6use std::str::FromStr;
7
8use crate::error::QueryError;
9
10/// Query parameters from an agent URI.
11///
12/// Stores key-value pairs from the query string, sorted lexicographically
13/// by key for consistent normalization.
14///
15/// # Reserved Parameters
16///
17/// - `version`: Capability version constraint (semver)
18/// - `attestation`: Inline PASETO attestation token
19/// - `resolver`: Hint for resolution endpoint
20/// - `ttl`: Cache TTL hint in seconds
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct QueryParams {
23    params: BTreeMap<String, String>,
24}
25
26impl QueryParams {
27    /// Creates an empty query params instance.
28    #[must_use]
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Parses query parameters from a query string (without leading '?').
34    ///
35    /// # Errors
36    ///
37    /// Returns `QueryError` if any parameter is invalid.
38    pub fn parse(input: &str) -> Result<Self, QueryError> {
39        if input.is_empty() {
40            return Ok(Self::new());
41        }
42
43        let mut params = BTreeMap::new();
44
45        for pair in input.split('&') {
46            if pair.is_empty() {
47                continue;
48            }
49
50            let (name, value) = if let Some(eq_idx) = pair.find('=') {
51                let name = &pair[..eq_idx];
52                let value = &pair[eq_idx + 1..];
53                (name, value)
54            } else {
55                (pair, "")
56            };
57
58            // Validate name
59            Self::validate_param_name(name)?;
60
61            // Validate and decode value
62            let decoded_value = Self::decode_value(name, value)?;
63
64            // Check for duplicates
65            if params.contains_key(name) {
66                return Err(QueryError::DuplicateParam {
67                    name: name.to_string(),
68                });
69            }
70
71            params.insert(name.to_string(), decoded_value);
72        }
73
74        Ok(Self { params })
75    }
76
77    /// Returns the value for a parameter, if present.
78    #[must_use]
79    pub fn get(&self, name: &str) -> Option<&str> {
80        self.params.get(name).map(String::as_str)
81    }
82
83    /// Returns true if the query is empty.
84    #[must_use]
85    pub fn is_empty(&self) -> bool {
86        self.params.is_empty()
87    }
88
89    /// Returns the number of parameters.
90    #[must_use]
91    pub fn len(&self) -> usize {
92        self.params.len()
93    }
94
95    /// Returns an iterator over the parameters.
96    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
97        self.params.iter().map(|(k, v)| (k.as_str(), v.as_str()))
98    }
99
100    /// Returns the version parameter, if present.
101    #[must_use]
102    pub fn version(&self) -> Option<&str> {
103        self.get("version")
104    }
105
106    /// Returns the TTL parameter, if present.
107    #[must_use]
108    pub fn ttl(&self) -> Option<u64> {
109        self.get("ttl").and_then(|s| s.parse().ok())
110    }
111
112    /// Returns a new query with the given parameter added or updated.
113    ///
114    /// # Errors
115    ///
116    /// Returns `QueryError` if the key or value is invalid.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use agent_uri::QueryParams;
122    ///
123    /// let query = QueryParams::new();
124    /// let updated = query.with_param("version", "2.0").unwrap();
125    /// assert_eq!(updated.get("version"), Some("2.0"));
126    /// ```
127    pub fn with_param(&self, key: &str, value: &str) -> Result<Self, QueryError> {
128        Self::validate_param_name(key)?;
129        // Validate value doesn't contain invalid characters
130        for c in value.chars() {
131            if !c.is_ascii_alphanumeric() && !"-_.".contains(c) {
132                return Err(QueryError::InvalidParamValue {
133                    name: key.to_string(),
134                    value: value.to_string(),
135                    reason: "value contains invalid character",
136                });
137            }
138        }
139
140        let mut params = self.params.clone();
141        params.insert(key.to_string(), value.to_string());
142        Ok(Self { params })
143    }
144
145    /// Returns a new query without the given parameter.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use agent_uri::QueryParams;
151    ///
152    /// let query = QueryParams::parse("version=2.0&ttl=300").unwrap();
153    /// let updated = query.without_param("ttl");
154    /// assert!(updated.get("ttl").is_none());
155    /// assert_eq!(updated.get("version"), Some("2.0"));
156    /// ```
157    #[must_use]
158    pub fn without_param(&self, key: &str) -> Self {
159        let mut params = self.params.clone();
160        params.remove(key);
161        Self { params }
162    }
163
164    /// Returns a new query with the version parameter set.
165    ///
166    /// # Errors
167    ///
168    /// Returns `QueryError` if the version string is invalid.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use agent_uri::QueryParams;
174    ///
175    /// let query = QueryParams::new();
176    /// let updated = query.with_version("2.0").unwrap();
177    /// assert_eq!(updated.version(), Some("2.0"));
178    /// ```
179    pub fn with_version(&self, v: &str) -> Result<Self, QueryError> {
180        self.with_param("version", v)
181    }
182
183    /// Returns a new query with the TTL parameter set.
184    ///
185    /// # Errors
186    ///
187    /// Returns `QueryError` if the resulting query would be invalid.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use agent_uri::QueryParams;
193    ///
194    /// let query = QueryParams::new();
195    /// let updated = query.with_ttl(300).unwrap();
196    /// assert_eq!(updated.ttl(), Some(300));
197    /// ```
198    pub fn with_ttl(&self, ttl: u64) -> Result<Self, QueryError> {
199        self.with_param("ttl", &ttl.to_string())
200    }
201
202    fn validate_param_name(name: &str) -> Result<(), QueryError> {
203        if name.is_empty() {
204            return Err(QueryError::InvalidParamName {
205                name: name.to_string(),
206                reason: "parameter name cannot be empty",
207            });
208        }
209
210        for c in name.chars() {
211            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
212                return Err(QueryError::InvalidParamName {
213                    name: name.to_string(),
214                    reason: "name must be alphanumeric, hyphen, or underscore",
215                });
216            }
217        }
218
219        Ok(())
220    }
221
222    fn decode_value(name: &str, value: &str) -> Result<String, QueryError> {
223        let mut decoded = String::with_capacity(value.len());
224        let mut chars = value.chars().peekable();
225
226        while let Some(c) = chars.next() {
227            if c == '%' {
228                let hex: String = chars.by_ref().take(2).collect();
229                if hex.len() != 2 {
230                    return Err(QueryError::InvalidPercentEncoding {
231                        value: value.to_string(),
232                    });
233                }
234                let byte =
235                    u8::from_str_radix(&hex, 16).map_err(|_| QueryError::InvalidPercentEncoding {
236                        value: value.to_string(),
237                    })?;
238                decoded.push(byte as char);
239            } else if c.is_ascii_alphanumeric() || "-_.".contains(c) {
240                decoded.push(c);
241            } else {
242                return Err(QueryError::InvalidParamValue {
243                    name: name.to_string(),
244                    value: value.to_string(),
245                    reason: "contains invalid unencoded character",
246                });
247            }
248        }
249
250        Ok(decoded)
251    }
252}
253
254impl fmt::Display for QueryParams {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        let pairs: Vec<String> = self
257            .params
258            .iter()
259            .map(|(k, v)| {
260                if v.is_empty() {
261                    k.clone()
262                } else {
263                    format!("{k}={v}")
264                }
265            })
266            .collect();
267        write!(f, "{}", pairs.join("&"))
268    }
269}
270
271impl FromStr for QueryParams {
272    type Err = QueryError;
273
274    fn from_str(s: &str) -> Result<Self, Self::Err> {
275        Self::parse(s)
276    }
277}
278
279impl TryFrom<&str> for QueryParams {
280    type Error = QueryError;
281
282    fn try_from(s: &str) -> Result<Self, Self::Error> {
283        Self::parse(s)
284    }
285}
286
287impl PartialOrd for QueryParams {
288    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293impl Ord for QueryParams {
294    fn cmp(&self, other: &Self) -> Ordering {
295        self.to_string().cmp(&other.to_string())
296    }
297}
298
299#[cfg(feature = "serde")]
300impl serde::Serialize for QueryParams {
301    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
302    where
303        S: serde::Serializer,
304    {
305        serializer.serialize_str(&self.to_string())
306    }
307}
308
309#[cfg(feature = "serde")]
310impl<'de> serde::Deserialize<'de> for QueryParams {
311    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
312    where
313        D: serde::Deserializer<'de>,
314    {
315        let s = String::deserialize(deserializer)?;
316        Self::parse(&s).map_err(serde::de::Error::custom)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn parse_empty() {
326        let params = QueryParams::parse("").unwrap();
327        assert!(params.is_empty());
328    }
329
330    #[test]
331    fn parse_single_param() {
332        let params = QueryParams::parse("version=2.0").unwrap();
333        assert_eq!(params.get("version"), Some("2.0"));
334        assert_eq!(params.len(), 1);
335    }
336
337    #[test]
338    fn parse_multiple_params() {
339        let params = QueryParams::parse("version=2.0&ttl=300").unwrap();
340        assert_eq!(params.get("version"), Some("2.0"));
341        assert_eq!(params.get("ttl"), Some("300"));
342        assert_eq!(params.len(), 2);
343    }
344
345    #[test]
346    fn parse_param_without_value() {
347        let params = QueryParams::parse("flag").unwrap();
348        assert_eq!(params.get("flag"), Some(""));
349    }
350
351    #[test]
352    fn parse_percent_encoded() {
353        let params = QueryParams::parse("name=%41%42%43").unwrap();
354        assert_eq!(params.get("name"), Some("ABC"));
355    }
356
357    #[test]
358    fn version_helper() {
359        let params = QueryParams::parse("version=2.0").unwrap();
360        assert_eq!(params.version(), Some("2.0"));
361    }
362
363    #[test]
364    fn ttl_helper() {
365        let params = QueryParams::parse("ttl=300").unwrap();
366        assert_eq!(params.ttl(), Some(300));
367    }
368
369    #[test]
370    fn ttl_invalid_returns_none() {
371        let params = QueryParams::parse("ttl=invalid").unwrap();
372        assert_eq!(params.ttl(), None);
373    }
374
375    #[test]
376    fn parse_duplicate_fails() {
377        let result = QueryParams::parse("version=1.0&version=2.0");
378        assert!(matches!(result, Err(QueryError::DuplicateParam { .. })));
379    }
380
381    #[test]
382    fn parse_invalid_name_fails() {
383        let result = QueryParams::parse("invalid@name=value");
384        assert!(matches!(result, Err(QueryError::InvalidParamName { .. })));
385    }
386
387    #[test]
388    fn parse_invalid_encoding_fails() {
389        let result = QueryParams::parse("name=%GG");
390        assert!(matches!(result, Err(QueryError::InvalidPercentEncoding { .. })));
391    }
392
393    #[test]
394    fn parse_invalid_value_char_fails() {
395        let result = QueryParams::parse("name=value with space");
396        assert!(matches!(result, Err(QueryError::InvalidParamValue { .. })));
397    }
398
399    #[test]
400    fn display_sorted() {
401        let params = QueryParams::parse("z=1&a=2").unwrap();
402        // BTreeMap sorts by key
403        assert_eq!(params.to_string(), "a=2&z=1");
404    }
405
406    #[test]
407    fn iter_returns_all_params() {
408        let params = QueryParams::parse("a=1&b=2").unwrap();
409        let items: Vec<_> = params.iter().collect();
410        assert_eq!(items, vec![("a", "1"), ("b", "2")]);
411    }
412}