Skip to main content

modkit/api/
odata.rs

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