use std::collections::BTreeMap;
use percent_encoding::percent_decode_str;
use crate::FieldsetConfig;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SortField {
pub field: String,
pub descending: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Query {
pub sort: Vec<SortField>,
pub include: Vec<String>,
pub fields: FieldsetConfig,
pub page: BTreeMap<String, String>,
pub filter: BTreeMap<String, Vec<String>>,
}
impl Query {
#[must_use = "parsing result should be used"]
pub fn from_pairs(pairs: &[(&str, &str)]) -> crate::Result<Query> {
let mut query = Query::default();
for (key, value) in pairs {
let key = *key;
let value = *value;
match key {
"sort" => {
for token in value.split(',') {
let (field, descending) = match token.strip_prefix('-') {
Some(rest) => (rest, true),
None => (token, false),
};
if field.is_empty() {
return Err(crate::Error::QueryParse {
param: "sort".into(),
reason: "empty sort field".into(),
});
}
query.sort.push(SortField {
field: field.to_string(),
descending,
});
}
}
"include" => {
for path in value.split(',') {
if path.is_empty() {
return Err(crate::Error::QueryParse {
param: "include".into(),
reason: "empty include path".into(),
});
}
query.include.push(path.to_string());
}
}
_ if key.starts_with("fields[") => {
let type_name = bracket_inner(key, "fields")?;
let names: Vec<&str> = value.split(',').filter(|s| !s.is_empty()).collect();
query.fields = std::mem::take(&mut query.fields).fields(&type_name, &names);
}
_ if key.starts_with("page[") => {
let inner = bracket_inner(key, "page")?;
query.page.insert(inner, value.to_string());
}
_ if key.starts_with("filter[") => {
let inner = bracket_inner(key, "filter")?;
query
.filter
.entry(inner)
.or_default()
.push(value.to_string());
}
_ => { }
}
}
Ok(query)
}
#[must_use = "parsing result should be used"]
pub fn from_query_string(query: &str) -> crate::Result<Query> {
let trimmed = query.strip_prefix('?').unwrap_or(query);
if trimmed.is_empty() {
return Ok(Query::default());
}
let mut decoded: Vec<(String, String)> = Vec::new();
for pair in trimmed.split('&') {
if pair.is_empty() {
continue;
}
let (raw_key, raw_val) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
let key = decode(raw_key)?;
let val = decode(raw_val)?;
decoded.push((key, val));
}
let pairs: Vec<(&str, &str)> = decoded
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
Query::from_pairs(&pairs)
}
}
fn bracket_inner(key: &str, prefix: &str) -> crate::Result<String> {
let after = &key[prefix.len() + 1..];
match after.strip_suffix(']') {
Some(inner) if !inner.is_empty() => Ok(inner.to_string()),
_ => Err(crate::Error::QueryParse {
param: key.to_string(),
reason: format!("malformed `{prefix}[...]` parameter"),
}),
}
}
fn decode(s: &str) -> crate::Result<String> {
percent_decode_str(s)
.decode_utf8()
.map(|cow| cow.into_owned())
.map_err(|e| crate::Error::QueryParse {
param: s.to_string(),
reason: format!("invalid percent-encoding: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_sort_with_descending() {
let q = Query::from_pairs(&[("sort", "-created,title")]).unwrap();
assert_eq!(q.sort.len(), 2);
assert_eq!(
q.sort[0],
SortField {
field: "created".into(),
descending: true
}
);
assert_eq!(
q.sort[1],
SortField {
field: "title".into(),
descending: false
}
);
}
#[test]
fn empty_sort_token_is_error() {
let err = Query::from_pairs(&[("sort", "a,,b")]).unwrap_err();
assert!(matches!(err, crate::Error::QueryParse { .. }));
}
#[test]
fn parses_include_paths() {
let q = Query::from_pairs(&[("include", "author,comments.author")]).unwrap();
assert_eq!(
q.include,
vec!["author".to_string(), "comments.author".to_string()]
);
}
#[test]
fn parses_fields_into_fieldset_config() {
let q = Query::from_pairs(&[("fields[articles]", "title,body")]).unwrap();
assert!(q.fields.has_type("articles"));
assert!(q.fields.is_included("articles", "title"));
assert!(q.fields.is_included("articles", "body"));
assert!(!q.fields.is_included("articles", "secret"));
}
#[test]
fn parses_page_into_generic_map() {
let q = Query::from_pairs(&[("page[size]", "25"), ("page[after]", "abc")]).unwrap();
assert_eq!(q.page.get("size").map(String::as_str), Some("25"));
assert_eq!(q.page.get("after").map(String::as_str), Some("abc"));
}
#[test]
fn parses_filter_accumulating_repeated_keys() {
let q = Query::from_pairs(&[("filter[tag]", "a"), ("filter[tag]", "b")]).unwrap();
assert_eq!(
q.filter.get("tag").unwrap(),
&vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn bracket_param_without_close_is_error() {
let err = Query::from_pairs(&[("filter[tag", "a")]).unwrap_err();
assert!(matches!(err, crate::Error::QueryParse { .. }));
}
#[test]
fn unknown_top_level_param_is_ignored() {
let q = Query::from_pairs(&[("unknown", "x"), ("sort", "title")]).unwrap();
assert_eq!(q.sort.len(), 1);
}
#[test]
fn from_query_string_matches_from_pairs_and_decodes() {
let q = Query::from_query_string("?sort=-created&filter[name]=john%20doe").unwrap();
assert_eq!(
q.sort[0],
SortField {
field: "created".into(),
descending: true
}
);
assert_eq!(q.filter.get("name").unwrap(), &vec!["john doe".to_string()]);
}
#[test]
fn from_query_string_empty_is_default() {
let q = Query::from_query_string("").unwrap();
assert_eq!(q, Query::default());
}
#[test]
fn page_duplicate_key_is_last_wins_but_filter_accumulates() {
let q = Query::from_pairs(&[
("page[size]", "10"),
("page[size]", "20"),
("filter[t]", "a"),
("filter[t]", "b"),
])
.unwrap();
assert_eq!(q.page.get("size").map(String::as_str), Some("20"));
assert_eq!(
q.filter.get("t").unwrap(),
&vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn from_query_string_rejects_invalid_percent_encoding() {
let err = Query::from_query_string("?sort=%FF").unwrap_err();
assert!(matches!(err, crate::Error::QueryParse { .. }));
}
}