Skip to main content

cratestack_axum/
query.rs

1//! Query-string parsing for axum-bound handlers: percent-decoded pair
2//! extraction and the structured filter expression grammar
3//! (`?where=...`) used by macro-generated `list` endpoints.
4
5use cratestack_core::CoolError;
6use url::form_urlencoded;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum QueryExpr {
10    Predicate { key: String, value: String },
11    All(Vec<QueryExpr>),
12    Any(Vec<QueryExpr>),
13    Not(Box<QueryExpr>),
14}
15
16pub fn parse_query_pairs(raw_query: Option<&str>) -> Result<Vec<(String, String)>, CoolError> {
17    let Some(raw_query) = raw_query else {
18        return Ok(Vec::new());
19    };
20
21    let mut pairs = Vec::new();
22    for (key, value) in form_urlencoded::parse(raw_query.as_bytes()) {
23        pairs.push((key.into_owned(), value.into_owned()));
24    }
25    Ok(pairs)
26}
27
28pub fn parse_filter_expression(input: &str) -> Result<QueryExpr, CoolError> {
29    let mut parser = FilterExpressionParser::new(input);
30    let expr = parser.parse_expr()?;
31    parser.skip_whitespace();
32    if !parser.is_eof() {
33        return Err(CoolError::BadRequest(format!(
34            "unexpected trailing filter expression content near '{}'",
35            parser.remaining(),
36        )));
37    }
38    Ok(expr)
39}
40
41pub(crate) struct FilterExpressionParser<'a> {
42    input: &'a str,
43    cursor: usize,
44}
45
46impl<'a> FilterExpressionParser<'a> {
47    fn new(input: &'a str) -> Self {
48        Self { input, cursor: 0 }
49    }
50
51    fn parse_expr(&mut self) -> Result<QueryExpr, CoolError> {
52        self.parse_or()
53    }
54
55    fn parse_or(&mut self) -> Result<QueryExpr, CoolError> {
56        let mut nodes = vec![self.parse_and()?];
57        loop {
58            self.skip_whitespace();
59            if !self.consume('|') {
60                break;
61            }
62            nodes.push(self.parse_and()?);
63        }
64        Ok(if nodes.len() == 1 {
65            nodes.pop().expect("single node should exist")
66        } else {
67            QueryExpr::Any(nodes)
68        })
69    }
70
71    fn parse_and(&mut self) -> Result<QueryExpr, CoolError> {
72        let mut nodes = vec![self.parse_factor()?];
73        loop {
74            self.skip_whitespace();
75            if !self.consume(',') {
76                break;
77            }
78            nodes.push(self.parse_factor()?);
79        }
80        Ok(if nodes.len() == 1 {
81            nodes.pop().expect("single node should exist")
82        } else {
83            QueryExpr::All(nodes)
84        })
85    }
86
87    fn parse_factor(&mut self) -> Result<QueryExpr, CoolError> {
88        self.skip_whitespace();
89        if self.consume_keyword("not") {
90            self.skip_whitespace();
91            if !self.consume('(') {
92                return Err(CoolError::BadRequest(
93                    "negated filter expression must use not(...)".to_owned(),
94                ));
95            }
96            let expr = self.parse_expr()?;
97            self.skip_whitespace();
98            if !self.consume(')') {
99                return Err(CoolError::BadRequest(
100                    "unterminated negated filter expression".to_owned(),
101                ));
102            }
103            return Ok(QueryExpr::Not(Box::new(expr)));
104        }
105        if self.consume('(') {
106            let expr = self.parse_expr()?;
107            self.skip_whitespace();
108            if !self.consume(')') {
109                return Err(CoolError::BadRequest(
110                    "unterminated grouped filter expression".to_owned(),
111                ));
112            }
113            return Ok(expr);
114        }
115
116        self.parse_predicate()
117    }
118
119    fn parse_predicate(&mut self) -> Result<QueryExpr, CoolError> {
120        let start = self.cursor;
121        while let Some(ch) = self.peek() {
122            if matches!(ch, ',' | '|' | ')') {
123                break;
124            }
125            self.cursor += ch.len_utf8();
126        }
127        let raw = self.input[start..self.cursor].trim();
128        let (key, value) = raw.split_once('=').ok_or_else(|| {
129            CoolError::BadRequest(format!(
130                "invalid grouped filter '{}': expected key=value",
131                raw,
132            ))
133        })?;
134        if key.trim().is_empty() || value.trim().is_empty() {
135            return Err(CoolError::BadRequest(format!(
136                "invalid grouped filter '{}': expected non-empty key and value",
137                raw,
138            )));
139        }
140        Ok(QueryExpr::Predicate {
141            key: key.trim().to_owned(),
142            value: value.trim().to_owned(),
143        })
144    }
145
146    fn consume(&mut self, expected: char) -> bool {
147        match self.peek() {
148            Some(ch) if ch == expected => {
149                self.cursor += ch.len_utf8();
150                true
151            }
152            _ => false,
153        }
154    }
155
156    fn consume_keyword(&mut self, expected: &str) -> bool {
157        let remaining = &self.input[self.cursor..];
158        if !remaining.starts_with(expected) {
159            return false;
160        }
161        let boundary = remaining[expected.len()..].chars().next();
162        if boundary.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
163            return false;
164        }
165        self.cursor += expected.len();
166        true
167    }
168
169    fn peek(&self) -> Option<char> {
170        self.input[self.cursor..].chars().next()
171    }
172
173    fn skip_whitespace(&mut self) {
174        while let Some(ch) = self.peek() {
175            if !ch.is_whitespace() {
176                break;
177            }
178            self.cursor += ch.len_utf8();
179        }
180    }
181
182    fn remaining(&self) -> &str {
183        &self.input[self.cursor..]
184    }
185
186    fn is_eof(&self) -> bool {
187        self.cursor >= self.input.len()
188    }
189}