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    let Query(params) = Query::<ODataParams>::from_request_parts(parts, state)
152        .await
153        .map_err(|e| crate::api::bad_request(format!("Invalid query parameters: {e}")))?;
154
155    let mut query = ODataQuery::new();
156
157    // Parse filter
158    if let Some(raw_filter) = params.filter.as_ref() {
159        let raw = raw_filter.trim();
160        if !raw.is_empty() {
161            if raw.len() > MAX_FILTER_LEN {
162                return Err(crate::api::bad_request("Filter too long"));
163            }
164
165            // Parse filter string using modkit-odata
166            let parsed = modkit_odata::parse_filter_string(raw).map_err(|e| {
167                // Log parser details for debugging (no PII - only length)
168                tracing::debug!(error = %e, filter_len = raw.len(), "OData filter parsing failed");
169
170                // Delegate to centralized error mapping (single source of truth)
171                // This handles ParsingUnavailable → 500 and InvalidFilter → 400
172                crate::api::odata::odata_error_to_problem(&e, parts.uri.path(), None)
173            })?;
174
175            if parsed.node_count() > MAX_NODES {
176                tracing::debug!(
177                    node_count = parsed.node_count(),
178                    max_nodes = MAX_NODES,
179                    "Filter complexity budget exceeded"
180                );
181                return Err(crate::api::bad_request("Filter too complex"));
182            }
183
184            // Generate filter hash for cursor consistency (use non-consuming accessor)
185            let filter_hash = modkit_odata::pagination::short_filter_hash(Some(parsed.as_expr()));
186
187            // Extract expression for query
188            let core_expr = parsed.into_expr();
189
190            query = query.with_filter(core_expr);
191            if let Some(hash) = filter_hash {
192                query = query.with_filter_hash(hash);
193            }
194        }
195    }
196
197    // Check for cursor+orderby conflict before parsing either
198    if params.cursor.is_some() && params.orderby.is_some() {
199        return Err(crate::api::odata::odata_error_to_problem(
200            &ODataError::OrderWithCursor,
201            "/",
202            None,
203        ));
204    }
205
206    // Parse cursor first (if present, skip orderby)
207    if let Some(cursor_str) = params.cursor.as_ref() {
208        let cursor = CursorV1::decode(cursor_str).map_err(|_| {
209            crate::api::odata::odata_error_to_problem(&ODataError::InvalidCursor, "/", None)
210        })?;
211        query = query.with_cursor(cursor);
212        // When cursor is present, order is empty (derived from cursor.s later)
213        query = query.with_order(ODataOrderBy::empty());
214    } else if let Some(raw_orderby) = params.orderby.as_ref() {
215        // Parse orderby only when cursor is absent
216        let order = parse_orderby(raw_orderby)
217            .map_err(|e| crate::api::odata::odata_error_to_problem(&e, "/", None))?;
218        query = query.with_order(order);
219    }
220
221    // Parse limit
222    if let Some(limit) = params.limit {
223        if limit == 0 {
224            return Err(crate::api::odata::odata_error_to_problem(
225                &ODataError::InvalidLimit,
226                "/",
227                None,
228            ));
229        }
230        query = query.with_limit(limit);
231    }
232
233    // Parse select
234    if let Some(raw_select) = params.select.as_ref() {
235        let fields = parse_select(raw_select)?;
236        query = query.with_select(fields);
237    }
238
239    Ok(query)
240}
241
242use std::ops::Deref;
243
244/// Simple Axum extractor for full `OData` query parameters.
245/// Parses $filter, $orderby, limit, and cursor parameters.
246/// Usage in handlers:
247///   async fn `list_users(OData(query)`: `OData`, /* ... */) { /* use `query` */ }
248#[derive(Debug, Clone)]
249pub struct OData(pub ODataQuery);
250
251impl OData {
252    #[inline]
253    pub fn into_inner(self) -> ODataQuery {
254        self.0
255    }
256}
257
258impl Deref for OData {
259    type Target = ODataQuery;
260    #[inline]
261    fn deref(&self) -> &Self::Target {
262        &self.0
263    }
264}
265
266impl AsRef<ODataQuery> for OData {
267    #[inline]
268    fn as_ref(&self) -> &ODataQuery {
269        &self.0
270    }
271}
272
273impl From<OData> for ODataQuery {
274    #[inline]
275    fn from(x: OData) -> Self {
276        x.0
277    }
278}
279
280impl<S> FromRequestParts<S> for OData
281where
282    S: Send + Sync,
283{
284    type Rejection = crate::api::problem::Problem;
285
286    #[allow(clippy::manual_async_fn)]
287    fn from_request_parts(
288        parts: &mut Parts,
289        state: &S,
290    ) -> impl core::future::Future<Output = Result<Self, Self::Rejection>> + Send {
291        async move {
292            let query = extract_odata_query(parts, state).await?;
293            Ok(OData(query))
294        }
295    }
296}
297
298#[cfg(test)]
299#[cfg_attr(coverage_nightly, coverage(off))]
300#[path = "odata_tests.rs"]
301mod odata_tests;