Skip to main content

toolkit/api/
odata.rs

1use axum::extract::{FromRequestParts, Query};
2use axum::http::request::Parts;
3use serde::Deserialize;
4use toolkit_canonical_errors::CanonicalError;
5use toolkit_odata::errors::OdataError;
6use toolkit_odata::{CursorV1, Error as ODataError, ODataOrderBy, OrderKey, SortDir};
7
8// Re-export types from toolkit-odata for convenience and better DX
9pub use toolkit_odata::ODataQuery;
10// CursorV1 is available through the private import above for internal use
11
12#[derive(Deserialize, Default)]
13pub struct ODataParams {
14    #[serde(rename = "$filter")]
15    pub filter: Option<String>,
16    #[serde(rename = "$orderby")]
17    pub orderby: Option<String>,
18    #[serde(rename = "$select")]
19    pub select: Option<String>,
20    pub limit: Option<u64>,
21    pub cursor: Option<String>,
22}
23
24pub const MAX_FILTER_LEN: usize = 8 * 1024;
25pub const MAX_NODES: usize = 2000;
26pub const MAX_ORDERBY_LEN: usize = 1024;
27pub const MAX_ORDER_FIELDS: usize = 10;
28pub const MAX_SELECT_LEN: usize = 2048;
29pub const MAX_SELECT_FIELDS: usize = 100;
30
31/// Build a canonical `InvalidArgument` keyed by the `$select` field.
32fn select_invalid_arg(detail: impl Into<String>, reason: &'static str) -> CanonicalError {
33    OdataError::invalid_argument()
34        .with_field_violation("$select", detail, reason)
35        .create()
36}
37
38/// Parse $select string into a list of field names.
39/// Format: "field1, field2, field3, ..."
40/// Field names are case-insensitive and whitespace is trimmed.
41///
42/// # Errors
43/// Returns a `CanonicalError` if the select string is invalid. Axum renders it
44/// via `IntoResponse for CanonicalError`; the `canonical_error_middleware`
45/// fills `instance` / `trace_id` on the way out.
46#[allow(clippy::result_large_err)]
47pub fn parse_select(raw: &str) -> Result<Vec<String>, CanonicalError> {
48    let raw = raw.trim();
49    if raw.is_empty() {
50        return Err(select_invalid_arg(
51            "$select cannot be empty",
52            "INVALID_SELECT",
53        ));
54    }
55
56    if raw.len() > MAX_SELECT_LEN {
57        return Err(select_invalid_arg("$select too long", "INVALID_SELECT"));
58    }
59
60    let fields: Vec<String> = raw
61        .split(',')
62        .map(|f| f.trim().to_lowercase())
63        .filter(|f| !f.is_empty())
64        .collect();
65
66    if fields.is_empty() {
67        return Err(select_invalid_arg(
68            "$select must contain at least one field",
69            "INVALID_SELECT",
70        ));
71    }
72
73    if fields.len() > MAX_SELECT_FIELDS {
74        return Err(select_invalid_arg(
75            "$select contains too many fields",
76            "INVALID_SELECT",
77        ));
78    }
79
80    // Check for duplicate fields
81    let mut seen = std::collections::HashSet::new();
82    for field in &fields {
83        if !seen.insert(field.clone()) {
84            return Err(select_invalid_arg(
85                format!("duplicate field in $select: {field}"),
86                "INVALID_SELECT",
87            ));
88        }
89    }
90
91    Ok(fields)
92}
93
94/// Parse $orderby string into `ODataOrderBy`.
95/// Format: "field1 [asc|desc], field2 [asc|desc], ..."
96/// Default direction is asc if not specified.
97///
98/// # Errors
99/// Returns `toolkit_odata::Error::InvalidOrderByField` if the orderby string is invalid.
100pub fn parse_orderby(raw: &str) -> Result<ODataOrderBy, toolkit_odata::Error> {
101    let raw = raw.trim();
102    if raw.is_empty() {
103        return Ok(ODataOrderBy::empty());
104    }
105
106    if raw.len() > MAX_ORDERBY_LEN {
107        return Err(toolkit_odata::Error::InvalidOrderByField(
108            "orderby too long".into(),
109        ));
110    }
111
112    let mut keys = Vec::new();
113
114    for part in raw.split(',') {
115        let part = part.trim();
116        if part.is_empty() {
117            continue;
118        }
119
120        let tokens: Vec<&str> = part.split_whitespace().collect();
121        let (field, dir) = match tokens.as_slice() {
122            [field] | [field, "asc"] => (*field, SortDir::Asc),
123            [field, "desc"] => (*field, SortDir::Desc),
124            _ => {
125                return Err(toolkit_odata::Error::InvalidOrderByField(format!(
126                    "invalid orderby clause: {part}"
127                )));
128            }
129        };
130
131        if field.is_empty() {
132            return Err(toolkit_odata::Error::InvalidOrderByField(
133                "empty field name in orderby".into(),
134            ));
135        }
136
137        keys.push(OrderKey {
138            field: field.to_owned(),
139            dir,
140        });
141    }
142
143    if keys.len() > MAX_ORDER_FIELDS {
144        return Err(toolkit_odata::Error::InvalidOrderByField(
145            "too many order fields".into(),
146        ));
147    }
148
149    Ok(ODataOrderBy(keys))
150}
151
152/// Build a canonical `InvalidArgument` for the `$filter` field.
153fn filter_invalid_arg(detail: impl Into<String>, reason: &'static str) -> CanonicalError {
154    OdataError::invalid_argument()
155        .with_field_violation("$filter", detail, reason)
156        .create()
157}
158
159/// Build a canonical `InvalidArgument` for an unspecified query parameter
160/// (used for axum-level deserialization failures).
161fn query_params_invalid_arg(detail: impl Into<String>) -> CanonicalError {
162    OdataError::invalid_argument()
163        .with_field_violation("query", detail, "INVALID_QUERY_PARAMS")
164        .create()
165}
166
167/// Extract and validate full `OData` query from request parts.
168/// - Parses $filter, $orderby, limit, cursor
169/// - Enforces budgets and validates formats
170/// - Returns unified `ODataQuery`
171///
172/// # Errors
173/// Returns a `CanonicalError` if any `OData` parameter is invalid. Axum
174/// renders it as `application/problem+json` via `IntoResponse for
175/// CanonicalError`; `canonical_error_middleware` fills `instance` /
176/// `trace_id` on the way out.
177pub async fn extract_odata_query<S>(
178    parts: &mut Parts,
179    state: &S,
180) -> Result<ODataQuery, CanonicalError>
181where
182    S: Send + Sync,
183{
184    let Query(params) = Query::<ODataParams>::from_request_parts(parts, state)
185        .await
186        .map_err(|e| query_params_invalid_arg(format!("Invalid query parameters: {e}")))?;
187
188    let mut query = ODataQuery::new();
189
190    // Parse filter
191    if let Some(raw_filter) = params.filter.as_ref() {
192        let raw = raw_filter.trim();
193        if !raw.is_empty() {
194            if raw.len() > MAX_FILTER_LEN {
195                return Err(filter_invalid_arg("Filter too long", "FILTER_TOO_LONG"));
196            }
197
198            // Parse filter string using toolkit-odata
199            let parsed = toolkit_odata::parse_filter_string(raw).map_err(|e| {
200                // Length-only debug log; the canonical's `diagnostic()` carries
201                // the actual parser cause for `canonical_error_middleware`.
202                tracing::debug!(error = %e, filter_len = raw.len(), "OData filter parsing failed");
203                CanonicalError::from(e)
204            })?;
205
206            if parsed.node_count() > MAX_NODES {
207                tracing::debug!(
208                    node_count = parsed.node_count(),
209                    max_nodes = MAX_NODES,
210                    "Filter complexity budget exceeded"
211                );
212                return Err(filter_invalid_arg(
213                    "Filter too complex",
214                    "FILTER_TOO_COMPLEX",
215                ));
216            }
217
218            // Generate filter hash for cursor consistency (use non-consuming accessor)
219            let filter_hash = toolkit_odata::pagination::short_filter_hash(Some(parsed.as_expr()));
220
221            // Extract expression for query
222            let core_expr = parsed.into_expr();
223
224            query = query.with_filter(core_expr);
225            if let Some(hash) = filter_hash {
226                query = query.with_filter_hash(hash);
227            }
228        }
229    }
230
231    // Check for cursor+orderby conflict before parsing either
232    if params.cursor.is_some() && params.orderby.is_some() {
233        return Err(ODataError::OrderWithCursor.into());
234    }
235
236    // Parse cursor first (if present, skip orderby)
237    if let Some(cursor_str) = params.cursor.as_ref() {
238        let cursor = CursorV1::decode(cursor_str).map_err(|_| ODataError::InvalidCursor)?;
239        query = query.with_cursor(cursor);
240        // When cursor is present, order is empty (derived from cursor.s later)
241        query = query.with_order(ODataOrderBy::empty());
242    } else if let Some(raw_orderby) = params.orderby.as_ref() {
243        // Parse orderby only when cursor is absent
244        let order = parse_orderby(raw_orderby).map_err(CanonicalError::from)?;
245        query = query.with_order(order);
246    }
247
248    // Parse limit
249    if let Some(limit) = params.limit {
250        if limit == 0 {
251            return Err(ODataError::InvalidLimit.into());
252        }
253        query = query.with_limit(limit);
254    }
255
256    // Parse select
257    if let Some(raw_select) = params.select.as_ref() {
258        let fields = parse_select(raw_select)?;
259        query = query.with_select(fields);
260    }
261
262    Ok(query)
263}
264
265use std::ops::Deref;
266
267/// Simple Axum extractor for full `OData` query parameters.
268/// Parses $filter, $orderby, limit, and cursor parameters.
269/// Usage in handlers:
270///   async fn `list_users(OData(query)`: `OData`, /* ... */) { /* use `query` */ }
271#[derive(Debug, Clone)]
272pub struct OData(pub ODataQuery);
273
274impl OData {
275    #[inline]
276    pub fn into_inner(self) -> ODataQuery {
277        self.0
278    }
279}
280
281impl Deref for OData {
282    type Target = ODataQuery;
283    #[inline]
284    fn deref(&self) -> &Self::Target {
285        &self.0
286    }
287}
288
289impl AsRef<ODataQuery> for OData {
290    #[inline]
291    fn as_ref(&self) -> &ODataQuery {
292        &self.0
293    }
294}
295
296impl From<OData> for ODataQuery {
297    #[inline]
298    fn from(x: OData) -> Self {
299        x.0
300    }
301}
302
303impl<S> FromRequestParts<S> for OData
304where
305    S: Send + Sync,
306{
307    type Rejection = CanonicalError;
308
309    #[allow(clippy::manual_async_fn)]
310    fn from_request_parts(
311        parts: &mut Parts,
312        state: &S,
313    ) -> impl core::future::Future<Output = Result<Self, Self::Rejection>> + Send {
314        async move {
315            let query = extract_odata_query(parts, state).await?;
316            Ok(OData(query))
317        }
318    }
319}
320
321#[cfg(test)]
322#[cfg_attr(coverage_nightly, coverage(off))]
323#[path = "odata_tests.rs"]
324mod odata_tests;