Skip to main content

toolkit_odata/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2pub mod builder;
3pub mod errors;
4pub mod filter;
5pub mod limits;
6/// Internal `OData` filter representations.
7pub(crate) mod odata_filters;
8/// Internal `OData` filter parsing.
9mod odata_parse;
10pub mod page;
11pub mod pagination;
12pub mod problem_mapping;
13pub mod schema;
14
15pub use builder::QueryBuilder;
16pub use limits::ODataLimits;
17pub use page::{Page, PageInfo};
18pub use pagination::{normalize_filter_for_hash, short_filter_hash};
19pub use schema::{FieldRef, Schema};
20
21pub mod ast {
22    use bigdecimal::BigDecimal;
23    use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
24    use uuid::Uuid;
25
26    #[derive(Clone, Debug)]
27    pub enum Expr {
28        And(Box<Expr>, Box<Expr>),
29        Or(Box<Expr>, Box<Expr>),
30        Not(Box<Expr>),
31        Compare(Box<Expr>, CompareOperator, Box<Expr>),
32        In(Box<Expr>, Vec<Expr>),
33        Function(String, Vec<Expr>),
34        Identifier(String),
35        Value(Value),
36    }
37
38    impl Expr {
39        /// Combine two expressions with AND: `expr1 and expr2`
40        ///
41        /// # Example
42        ///
43        /// ```rust,ignore
44        /// let filter = ID.eq(user_id).and(NAME.contains("john"));
45        /// ```
46        #[must_use]
47        pub fn and(self, other: Expr) -> Expr {
48            Expr::And(Box::new(self), Box::new(other))
49        }
50
51        /// Combine two expressions with OR: `expr1 or expr2`
52        #[must_use]
53        pub fn or(self, other: Expr) -> Expr {
54            Expr::Or(Box::new(self), Box::new(other))
55        }
56
57        /// Negate an expression: `not expr`
58        #[must_use]
59        #[allow(clippy::should_implement_trait)]
60        pub fn not(self) -> Expr {
61            !self
62        }
63    }
64
65    impl std::ops::Not for Expr {
66        type Output = Expr;
67
68        fn not(self) -> Self::Output {
69            Expr::Not(Box::new(self))
70        }
71    }
72
73    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
74    pub enum CompareOperator {
75        Eq,
76        Ne,
77        Gt,
78        Ge,
79        Lt,
80        Le,
81    }
82
83    #[derive(Clone, Debug)]
84    pub enum Value {
85        Null,
86        Bool(bool),
87        Number(BigDecimal),
88        Uuid(Uuid),
89        DateTime(DateTime<Utc>),
90        Date(NaiveDate),
91        Time(NaiveTime),
92        String(String),
93    }
94
95    impl std::fmt::Display for Value {
96        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97            match self {
98                Value::Null => write!(f, "null"),
99                Value::Bool(_) => write!(f, "bool"),
100                Value::Number(_) => write!(f, "number"),
101                Value::Uuid(_) => write!(f, "uuid"),
102                Value::DateTime(_) => write!(f, "datetime"),
103                Value::Date(_) => write!(f, "date"),
104                Value::Time(_) => write!(f, "time"),
105                Value::String(_) => write!(f, "string"),
106            }
107        }
108    }
109}
110
111// Ordering primitives
112#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113pub enum SortDir {
114    #[serde(rename = "asc")]
115    Asc,
116    #[serde(rename = "desc")]
117    Desc,
118}
119
120impl SortDir {
121    /// Reverse the sort direction (Asc <-> Desc)
122    #[must_use]
123    pub fn reverse(self) -> Self {
124        match self {
125            SortDir::Asc => SortDir::Desc,
126            SortDir::Desc => SortDir::Asc,
127        }
128    }
129}
130
131#[derive(Clone, Debug)]
132pub struct OrderKey {
133    pub field: String,
134    pub dir: SortDir,
135}
136
137#[derive(Clone, Debug, Default)]
138#[must_use]
139pub struct ODataOrderBy(pub Vec<OrderKey>);
140
141impl ODataOrderBy {
142    pub fn empty() -> Self {
143        Self(vec![])
144    }
145
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.0.is_empty()
149    }
150
151    /// Render as "+f1,-f2" for cursor.s
152    #[must_use]
153    pub fn to_signed_tokens(&self) -> String {
154        self.0
155            .iter()
156            .map(|k| {
157                if matches!(k.dir, SortDir::Asc) {
158                    format!("+{}", k.field)
159                } else {
160                    format!("-{}", k.field)
161                }
162            })
163            .collect::<Vec<_>>()
164            .join(",")
165    }
166
167    /// Parse signed tokens back to `ODataOrderBy` (e.g. "+a,-b" -> `ODataOrderBy`)
168    /// Returns Error for stricter validation used in cursor processing
169    ///
170    /// # Errors
171    /// Returns `Error::InvalidOrderByField` if the input is empty or contains invalid field names.
172    pub fn from_signed_tokens(signed: &str) -> Result<Self, Error> {
173        let mut out = Vec::new();
174        for seg in signed.split(',') {
175            let seg = seg.trim();
176            if seg.is_empty() {
177                continue;
178            }
179            let (dir, name) = match seg.as_bytes()[0] {
180                b'+' => (SortDir::Asc, &seg[1..]),
181                b'-' => (SortDir::Desc, &seg[1..]),
182                _ => (SortDir::Asc, seg), // default '+'
183            };
184            if name.is_empty() {
185                return Err(Error::InvalidOrderByField(seg.to_owned()));
186            }
187            out.push(OrderKey {
188                field: name.to_owned(),
189                dir,
190            });
191        }
192        if out.is_empty() {
193            return Err(Error::InvalidOrderByField("empty order".into()));
194        }
195        Ok(ODataOrderBy(out))
196    }
197
198    /// Check equality against signed token list (e.g. "+a,-b")
199    #[must_use]
200    pub fn equals_signed_tokens(&self, signed: &str) -> bool {
201        let parse = |t: &str| -> Option<(String, SortDir)> {
202            let t = t.trim();
203            if t.is_empty() {
204                return None;
205            }
206            let (dir, name) = match t.as_bytes()[0] {
207                b'+' => (SortDir::Asc, &t[1..]),
208                b'-' => (SortDir::Desc, &t[1..]),
209                _ => (SortDir::Asc, t),
210            };
211            if name.is_empty() {
212                return None;
213            }
214            Some((name.to_owned(), dir))
215        };
216        let theirs: Vec<_> = signed.split(',').filter_map(parse).collect();
217        if theirs.len() != self.0.len() {
218            return false;
219        }
220        self.0
221            .iter()
222            .zip(theirs.iter())
223            .all(|(a, (n, d))| a.field == *n && a.dir == *d)
224    }
225
226    /// Append tiebreaker if missing
227    pub fn ensure_tiebreaker(mut self, tiebreaker: &str, dir: SortDir) -> Self {
228        if !self.0.iter().any(|k| k.field == tiebreaker) {
229            self.0.push(OrderKey {
230                field: tiebreaker.to_owned(),
231                dir,
232            });
233        }
234        self
235    }
236
237    /// Reverse all sort directions (for backward pagination)
238    pub fn reverse_directions(mut self) -> Self {
239        for key in &mut self.0 {
240            key.dir = key.dir.reverse();
241        }
242        self
243    }
244}
245
246// Display trait for human-readable orderby representation
247impl std::fmt::Display for ODataOrderBy {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        if self.0.is_empty() {
250            return write!(f, "(none)");
251        }
252
253        let formatted: Vec<String> = self
254            .0
255            .iter()
256            .map(|key| {
257                let dir_str = match key.dir {
258                    SortDir::Asc => "asc",
259                    SortDir::Desc => "desc",
260                };
261                format!("{} {}", key.field, dir_str)
262            })
263            .collect();
264
265        write!(f, "{}", formatted.join(", "))
266    }
267}
268
269/// Unified error type for all `OData` operations
270///
271/// This centralizes all OData-related errors including parsing, validation,
272/// pagination, and cursor operations into a single error type using thiserror.
273///
274/// ## HTTP Mapping
275///
276/// These errors map to canonical RFC 9457 Problem responses via
277/// `impl From<Error> for CanonicalError` in [`crate::problem_mapping`]:
278/// - `InvalidFilter` / `InvalidOrderByField` / cursor errors / cursor-vs-query
279///   mismatches / `InvalidLimit` / `OrderWithCursor` → canonical
280///   `InvalidArgument` (HTTP 400), with the offending parameter surfaced as
281///   a `field_violation` (`$filter`, `$orderby`, `$top` — the page-size slot,
282///   which `toolkit::api::odata` also accepts as `limit` — or `cursor`).
283/// - `Db` / `ParsingUnavailable` → canonical `Internal` (HTTP 500).
284#[derive(thiserror::Error, Debug, Clone)]
285pub enum Error {
286    // Filter parsing and validation errors
287    #[error("invalid $filter: {0}")]
288    InvalidFilter(String),
289
290    // OrderBy parsing and validation errors
291    #[error("unsupported $orderby field: {0}")]
292    InvalidOrderByField(String),
293
294    // Pagination and cursor errors
295    #[error("ORDER_MISMATCH")]
296    OrderMismatch,
297
298    #[error("FILTER_MISMATCH")]
299    FilterMismatch,
300
301    #[error("INVALID_CURSOR")]
302    InvalidCursor,
303
304    #[error("INVALID_LIMIT")]
305    InvalidLimit,
306
307    #[error("ORDER_WITH_CURSOR")]
308    OrderWithCursor,
309
310    // Cursor parsing errors (previously CursorError variants)
311    #[error("invalid cursor: invalid base64url encoding")]
312    CursorInvalidBase64,
313
314    #[error("invalid cursor: malformed JSON")]
315    CursorInvalidJson,
316
317    #[error("invalid cursor: unsupported version")]
318    CursorInvalidVersion,
319
320    #[error("invalid cursor: empty or invalid keys")]
321    CursorInvalidKeys,
322
323    #[error("invalid cursor: empty or invalid fields")]
324    CursorInvalidFields,
325
326    #[error("invalid cursor: invalid sort direction")]
327    CursorInvalidDirection,
328
329    // Database and low-level errors
330    #[error("database error: {0}")]
331    Db(String),
332
333    // Configuration errors
334    #[error("OData parsing unavailable: {0}")]
335    ParsingUnavailable(&'static str),
336}
337
338/// Validate cursor consistency against effective order and filter hash.
339///
340/// # Errors
341/// Returns `Error::OrderMismatch` if the cursor's sort order doesn't match the effective order.
342/// Returns `Error::FilterMismatch` if the cursor carries a filter hash and the effective filter
343/// differs from it, including a filtered cursor continued without its filter (the cursor carries
344/// only the hash, so the filter cannot be restored from it). A cursor without a hash is accepted
345/// under any filter: a filter added to it only narrows the page, and cursors issued before a
346/// fingerprint was bound keep working.
347pub fn validate_cursor_against(
348    cursor: &CursorV1,
349    effective_order: &ODataOrderBy,
350    effective_filter_hash: Option<&str>,
351) -> Result<(), Error> {
352    if !effective_order.equals_signed_tokens(&cursor.s) {
353        return Err(Error::OrderMismatch);
354    }
355    if let Some(cf) = cursor.f.as_deref()
356        && effective_filter_hash != Some(cf)
357    {
358        return Err(Error::FilterMismatch);
359    }
360    Ok(())
361}
362
363// Cursor v1
364#[derive(Clone, Debug)]
365pub struct CursorV1 {
366    pub k: Vec<String>,
367    pub o: SortDir,
368    pub s: String,
369    pub f: Option<String>,
370    pub d: String, // Direction: "fwd" (forward) or "bwd" (backward)
371}
372
373impl CursorV1 {
374    /// Encode cursor to a base64url string.
375    ///
376    /// # Errors
377    /// Returns a JSON serialization error if encoding fails.
378    pub fn encode(&self) -> serde_json::Result<String> {
379        #[derive(serde::Serialize)]
380        struct Wire<'a> {
381            v: u8,
382            k: &'a [String],
383            o: &'a str,
384            s: &'a str,
385            #[serde(skip_serializing_if = "Option::is_none")]
386            f: &'a Option<String>,
387            d: &'a str,
388        }
389        let o = match self.o {
390            SortDir::Asc => "asc",
391            SortDir::Desc => "desc",
392        };
393        let w = Wire {
394            v: 1,
395            k: &self.k,
396            o,
397            s: &self.s,
398            f: &self.f,
399            d: &self.d,
400        };
401        serde_json::to_vec(&w).map(|x| base64_url::encode(&x))
402    }
403
404    /// Decode cursor from base64url token.
405    ///
406    /// # Errors
407    /// Returns `Error::CursorInvalidBase64` if base64 decoding fails.
408    /// Returns `Error::CursorInvalidJson` if JSON parsing fails.
409    /// Returns `Error::CursorInvalidVersion` if the version is unsupported.
410    /// Returns `Error::CursorInvalidDirection` if the direction field is invalid.
411    pub fn decode(token: &str) -> Result<Self, Error> {
412        #[derive(serde::Deserialize)]
413        struct Wire {
414            v: u8,
415            k: Vec<String>,
416            o: String,
417            s: String,
418            #[serde(default)]
419            f: Option<String>,
420            #[serde(default = "default_direction")]
421            d: String,
422        }
423
424        fn default_direction() -> String {
425            "fwd".to_owned()
426        }
427
428        let bytes = base64_url::decode(token).map_err(|_| Error::CursorInvalidBase64)?;
429        let w: Wire = serde_json::from_slice(&bytes).map_err(|_| Error::CursorInvalidJson)?;
430        if w.v != 1 {
431            return Err(Error::CursorInvalidVersion);
432        }
433        let o = match w.o.as_str() {
434            "asc" => SortDir::Asc,
435            "desc" => SortDir::Desc,
436            _ => return Err(Error::CursorInvalidDirection),
437        };
438        if w.k.is_empty() {
439            return Err(Error::CursorInvalidKeys);
440        }
441        if w.s.trim().is_empty() {
442            return Err(Error::CursorInvalidFields);
443        }
444        // Validate direction
445        if w.d != "fwd" && w.d != "bwd" {
446            return Err(Error::CursorInvalidDirection);
447        }
448        Ok(CursorV1 {
449            k: w.k,
450            o,
451            s: w.s,
452            f: w.f,
453            d: w.d,
454        })
455    }
456}
457
458// base64url helpers (no padding)
459/// URL-safe base64 encoding without padding, used for cursor tokens.
460mod base64_url {
461    use base64::Engine;
462
463    /// Encodes bytes as URL-safe base64 without padding.
464    pub fn encode(bytes: &[u8]) -> String {
465        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
466    }
467
468    /// Decodes URL-safe base64 without padding.
469    pub fn decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
470        base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)
471    }
472}
473
474// The unified ODataQuery struct as single source of truth
475#[derive(Clone, Debug, Default)]
476#[must_use]
477pub struct ODataQuery {
478    pub filter: Option<Box<ast::Expr>>,
479    pub order: ODataOrderBy,
480    pub limit: Option<u64>,
481    pub cursor: Option<CursorV1>,
482    pub filter_hash: Option<String>,
483    pub select: Option<Vec<String>>,
484}
485
486impl ODataQuery {
487    pub fn new() -> Self {
488        Self::default()
489    }
490
491    pub fn with_filter(mut self, expr: ast::Expr) -> Self {
492        self.filter = Some(Box::new(expr));
493        self
494    }
495
496    pub fn with_order(mut self, order: ODataOrderBy) -> Self {
497        self.order = order;
498        self
499    }
500
501    pub fn with_limit(mut self, limit: u64) -> Self {
502        self.limit = Some(limit);
503        self
504    }
505
506    pub fn with_cursor(mut self, cursor: CursorV1) -> Self {
507        self.cursor = Some(cursor);
508        self
509    }
510
511    pub fn with_filter_hash(mut self, hash: String) -> Self {
512        self.filter_hash = Some(hash);
513        self
514    }
515
516    pub fn with_select(mut self, fields: Vec<String>) -> Self {
517        self.select = Some(fields);
518        self
519    }
520
521    /// Get filter as AST
522    #[must_use]
523    pub fn filter(&self) -> Option<&ast::Expr> {
524        self.filter.as_deref()
525    }
526
527    /// Check if filter is present
528    #[must_use]
529    pub fn has_filter(&self) -> bool {
530        self.filter.is_some()
531    }
532
533    /// Extract filter into AST
534    #[must_use]
535    pub fn into_filter(self) -> Option<ast::Expr> {
536        self.filter.map(|b| *b)
537    }
538
539    /// Check if field selection is present
540    #[must_use]
541    pub fn has_select(&self) -> bool {
542        self.select.is_some()
543    }
544
545    /// Get selected fields
546    #[must_use]
547    pub fn selected_fields(&self) -> Option<&[String]> {
548        self.select.as_deref()
549    }
550}
551
552impl From<Option<ast::Expr>> for ODataQuery {
553    fn from(opt: Option<ast::Expr>) -> Self {
554        match opt {
555            Some(e) => Self::default().with_filter(e),
556            None => Self::default(),
557        }
558    }
559}
560
561#[cfg(test)]
562mod odata_parse_tests;
563mod tests;
564
565mod convert_odata_filters {
566    use super::ast::{CompareOperator, Expr, Value};
567    use crate::odata_filters as od;
568
569    impl From<od::CompareOperator> for CompareOperator {
570        fn from(op: od::CompareOperator) -> Self {
571            use od::CompareOperator::{
572                Equal, GreaterOrEqual, GreaterThan, LessOrEqual, LessThan, NotEqual,
573            };
574            match op {
575                Equal => CompareOperator::Eq,
576                NotEqual => CompareOperator::Ne,
577                GreaterThan => CompareOperator::Gt,
578                GreaterOrEqual => CompareOperator::Ge,
579                LessThan => CompareOperator::Lt,
580                LessOrEqual => CompareOperator::Le,
581            }
582        }
583    }
584
585    impl From<od::Value> for Value {
586        fn from(v: od::Value) -> Self {
587            match v {
588                od::Value::Null => Value::Null,
589                od::Value::Bool(b) => Value::Bool(b),
590                od::Value::Number(n) => Value::Number(n),
591                od::Value::Uuid(u) => Value::Uuid(u),
592                od::Value::DateTime(dt) => Value::DateTime(dt),
593                od::Value::Date(d) => Value::Date(d),
594                od::Value::Time(t) => Value::Time(t),
595                od::Value::String(s) => Value::String(s),
596            }
597        }
598    }
599
600    impl From<od::Expr> for Expr {
601        fn from(e: od::Expr) -> Self {
602            use od::Expr::{And, Compare, Function, Identifier, In, Not, Or, Value};
603            match e {
604                And(a, b) => Expr::And(Box::new((*a).into()), Box::new((*b).into())),
605                Or(a, b) => Expr::Or(Box::new((*a).into()), Box::new((*b).into())),
606                Not(x) => Expr::Not(Box::new((*x).into())),
607                Compare(l, op, r) => {
608                    Expr::Compare(Box::new((*l).into()), op.into(), Box::new((*r).into()))
609                }
610                In(l, list) => Expr::In(
611                    Box::new((*l).into()),
612                    list.into_iter().map(Into::into).collect(),
613                ),
614                Function(n, args) => Expr::Function(n, args.into_iter().map(Into::into).collect()),
615                Identifier(s) => Expr::Identifier(s),
616                Value(v) => Expr::Value(v.into()),
617            }
618        }
619    }
620}
621
622/// Result of parsing a filter string, including both the AST and complexity metadata.
623#[derive(Clone, Debug)]
624pub struct ParsedFilter {
625    expr: ast::Expr,
626    node_count: usize,
627}
628
629impl ParsedFilter {
630    /// Get a reference to the parsed expression
631    #[must_use]
632    pub fn as_expr(&self) -> &ast::Expr {
633        &self.expr
634    }
635
636    /// Consume and extract the parsed expression
637    #[must_use]
638    pub fn into_expr(self) -> ast::Expr {
639        self.expr
640    }
641
642    /// Get the AST node count for budget enforcement
643    #[must_use]
644    pub fn node_count(&self) -> usize {
645        self.node_count
646    }
647}
648
649/// Parse a raw $filter string into internal AST with complexity metadata.
650///
651/// This function encapsulates the parsing logic and node counting.
652///
653/// # Errors
654/// - `Error::InvalidFilter` if the filter string is malformed or parsing fails
655///
656/// # Example
657/// ```ignore
658/// let result = parse_filter_string("name eq 'John' and age gt 18")?;
659/// if result.node_count() > MAX_NODES {
660///     return Err(Error::InvalidFilter("too complex".into()));
661/// }
662/// ```
663pub fn parse_filter_string(raw: &str) -> Result<ParsedFilter, Error> {
664    use crate::odata_filters as od;
665
666    /// Count nodes in AST for complexity budget enforcement.
667    fn count_ast_nodes(e: &od::Expr) -> usize {
668        use od::Expr::{And, Compare, Function, Identifier, In, Not, Or, Value};
669        match e {
670            Value(_) | Identifier(_) => 1,
671            Not(x) => 1 + count_ast_nodes(x),
672            And(a, b) | Or(a, b) | Compare(a, _, b) => 1 + count_ast_nodes(a) + count_ast_nodes(b),
673            In(a, list) => 1 + count_ast_nodes(a) + list.iter().map(count_ast_nodes).sum::<usize>(),
674            Function(_, args) => 1 + args.iter().map(count_ast_nodes).sum::<usize>(),
675        }
676    }
677
678    let ast_src = od::parse_str(raw).map_err(|e| Error::InvalidFilter(format!("{e}")))?;
679
680    let node_count = count_ast_nodes(&ast_src);
681    let expr: ast::Expr = ast_src.into();
682
683    Ok(ParsedFilter { expr, node_count })
684}