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/// Wire binding for the `OData` query-parameter family.
13///
14/// This struct is the single place where `OData` query parameters are bound
15/// off the URL: `toolkit-odata` operates on already-parsed values and does no
16/// HTTP query parsing of its own.
17#[derive(Deserialize, Default)]
18pub struct ODataParams {
19    #[serde(rename = "$filter")]
20    pub filter: Option<String>,
21    #[serde(rename = "$orderby")]
22    pub orderby: Option<String>,
23    #[serde(rename = "$select")]
24    pub select: Option<String>,
25    /// Page size. Accepted as `limit` or as the canonical `OData` spelling
26    /// `$top` (OASIS `OData` 4.01 Part 2: URL Conventions, §5.1.6 "System
27    /// Query Options $top and $skip") — both spellings fold onto this one
28    /// slot, so a gear needs no per-endpoint handling to honor either.
29    ///
30    /// Sending both in one request is ambiguous and is rejected with
31    /// `400 InvalidArgument` (serde reports the second spelling as a
32    /// duplicate field).
33    #[serde(alias = "$top")]
34    pub limit: Option<u64>,
35    /// Opaque keyset token from the previous page's `next_cursor`. Accepted
36    /// as `cursor` or as `$skiptoken`, `OData`'s opaque continuation token
37    /// for server-driven paging; both spellings fold onto this one slot.
38    ///
39    /// This is the platform's only pagination continuation. `$skip` — offset
40    /// paging — is not supported and is rejected; see
41    /// [`ACCEPTED_SYSTEM_QUERY_OPTIONS`].
42    #[serde(alias = "$skiptoken")]
43    pub cursor: Option<String>,
44}
45
46/// `OData` system query options this extractor binds, in the exact spelling
47/// that binds.
48///
49/// Any other `$`-prefixed query key is rejected rather than ignored. OASIS
50/// `OData` 4.01 Part 1: Protocol, §6.1 "Query Option Extensibility": a
51/// service "MUST fail any request that contains unsupported `OData` query
52/// options defined in the version of this specification supported by the
53/// service", and SHOULD fail any option it does not understand. §6.1 also
54/// reserves the `$` prefix for `OData`, so unprefixed keys stay out of
55/// scope — those belong to the handler's own params struct, and policing
56/// them is each gear's business.
57pub const ACCEPTED_SYSTEM_QUERY_OPTIONS: [&str; 5] =
58    ["$filter", "$orderby", "$select", "$top", "$skiptoken"];
59
60/// Every system query option `OData` 4.01 defines, whether or not this
61/// platform binds it (OASIS `OData` 4.01 Part 2: URL Conventions, §§5.1.2 —
62/// 5.1.12).
63///
64/// Splits the two ways a `$` key can be refused: one of these is
65/// *unsupported* and may become supported later, while a `$` key outside
66/// this set is *unknown* — which is what a typo like `$filtre` should hear.
67const ODATA_SYSTEM_QUERY_OPTIONS: [&str; 12] = [
68    "$filter",
69    "$expand",
70    "$select",
71    "$orderby",
72    "$top",
73    "$skip",
74    "$count",
75    "$search",
76    "$format",
77    "$compute",
78    "$index",
79    "$schemaversion",
80];
81
82/// Reason code carried by every unsupported-option violation.
83const UNSUPPORTED_QUERY_PARAM: &str = "UNSUPPORTED_QUERY_PARAM";
84
85pub const MAX_FILTER_LEN: usize = 8 * 1024;
86pub const MAX_NODES: usize = 2000;
87pub const MAX_ORDERBY_LEN: usize = 1024;
88pub const MAX_ORDER_FIELDS: usize = 10;
89pub const MAX_SELECT_LEN: usize = 2048;
90pub const MAX_SELECT_FIELDS: usize = 100;
91
92/// Build a canonical `InvalidArgument` keyed by the `$select` field.
93fn select_invalid_arg(detail: impl Into<String>, reason: &'static str) -> CanonicalError {
94    OdataError::invalid_argument()
95        .with_field_violation("$select", detail, reason)
96        .create()
97}
98
99/// Parse $select string into a list of field names.
100/// Format: "field1, field2, field3, ..."
101/// Field names are case-insensitive and whitespace is trimmed.
102///
103/// # Errors
104/// Returns a `CanonicalError` if the select string is invalid. Axum renders it
105/// via `IntoResponse for CanonicalError`; the `canonical_error_middleware`
106/// fills `instance` / `trace_id` on the way out.
107#[allow(clippy::result_large_err)]
108pub fn parse_select(raw: &str) -> Result<Vec<String>, CanonicalError> {
109    let raw = raw.trim();
110    if raw.is_empty() {
111        return Err(select_invalid_arg(
112            "$select cannot be empty",
113            "INVALID_SELECT",
114        ));
115    }
116
117    if raw.len() > MAX_SELECT_LEN {
118        return Err(select_invalid_arg("$select too long", "INVALID_SELECT"));
119    }
120
121    let fields: Vec<String> = raw
122        .split(',')
123        .map(|f| f.trim().to_lowercase())
124        .filter(|f| !f.is_empty())
125        .collect();
126
127    if fields.is_empty() {
128        return Err(select_invalid_arg(
129            "$select must contain at least one field",
130            "INVALID_SELECT",
131        ));
132    }
133
134    if fields.len() > MAX_SELECT_FIELDS {
135        return Err(select_invalid_arg(
136            "$select contains too many fields",
137            "INVALID_SELECT",
138        ));
139    }
140
141    // Check for duplicate fields
142    let mut seen = std::collections::HashSet::new();
143    for field in &fields {
144        if !seen.insert(field.clone()) {
145            return Err(select_invalid_arg(
146                format!("duplicate field in $select: {field}"),
147                "INVALID_SELECT",
148            ));
149        }
150    }
151
152    Ok(fields)
153}
154
155/// Parse $orderby string into `ODataOrderBy`.
156/// Format: "field1 [asc|desc], field2 [asc|desc], ..."
157/// Default direction is asc if not specified.
158///
159/// # Errors
160/// Returns `toolkit_odata::Error::InvalidOrderByField` if the orderby string is invalid.
161pub fn parse_orderby(raw: &str) -> Result<ODataOrderBy, toolkit_odata::Error> {
162    let raw = raw.trim();
163    if raw.is_empty() {
164        return Ok(ODataOrderBy::empty());
165    }
166
167    if raw.len() > MAX_ORDERBY_LEN {
168        return Err(toolkit_odata::Error::InvalidOrderByField(
169            "orderby too long".into(),
170        ));
171    }
172
173    let mut keys = Vec::new();
174
175    for part in raw.split(',') {
176        let part = part.trim();
177        if part.is_empty() {
178            continue;
179        }
180
181        let tokens: Vec<&str> = part.split_whitespace().collect();
182        let (field, dir) = match tokens.as_slice() {
183            [field] | [field, "asc"] => (*field, SortDir::Asc),
184            [field, "desc"] => (*field, SortDir::Desc),
185            _ => {
186                return Err(toolkit_odata::Error::InvalidOrderByField(format!(
187                    "invalid orderby clause: {part}"
188                )));
189            }
190        };
191
192        if field.is_empty() {
193            return Err(toolkit_odata::Error::InvalidOrderByField(
194                "empty field name in orderby".into(),
195            ));
196        }
197
198        keys.push(OrderKey {
199            field: field.to_owned(),
200            dir,
201        });
202    }
203
204    if keys.len() > MAX_ORDER_FIELDS {
205        return Err(toolkit_odata::Error::InvalidOrderByField(
206            "too many order fields".into(),
207        ));
208    }
209
210    Ok(ODataOrderBy(keys))
211}
212
213/// Build a canonical `InvalidArgument` for the `$filter` field.
214fn filter_invalid_arg(detail: impl Into<String>, reason: &'static str) -> CanonicalError {
215    OdataError::invalid_argument()
216        .with_field_violation("$filter", detail, reason)
217        .create()
218}
219
220/// Build a canonical `InvalidArgument` for an unspecified query parameter
221/// (used for axum-level deserialization failures).
222fn query_params_invalid_arg(detail: impl Into<String>) -> CanonicalError {
223    OdataError::invalid_argument()
224        .with_field_violation("query", detail, "INVALID_QUERY_PARAMS")
225        .create()
226}
227
228/// Replacement hint appended to the violation description, so a caller who
229/// sent a plausible option learns what to send instead of only that the
230/// option is refused.
231fn unsupported_option_hint(option: &str) -> &'static str {
232    match option {
233        "$skip" => {
234            "this platform pages by cursor, not by offset: send the previous \
235             page's `next_cursor` as `$skiptoken` (alias `cursor`)"
236        }
237        "$count" => {
238            "a page carries no total: `PageInfo` is \
239             `{next_cursor, prev_cursor, limit}`"
240        }
241        "$format" => {
242            "responses are JSON: negotiate the media type with the `Accept` \
243             header"
244        }
245        _ => "not implemented by this platform",
246    }
247}
248
249/// Describe one rejected `$`-prefixed query key.
250fn unsupported_option_detail(option: &str) -> String {
251    let canonical = option.to_ascii_lowercase();
252    if ACCEPTED_SYSTEM_QUERY_OPTIONS.contains(&canonical.as_str()) {
253        // The option is supported, the spelling is not: binding is
254        // case-sensitive, so honoring `$Top` would take a second code path.
255        // Say which spelling binds instead of dropping the request.
256        return format!("`{option}` binds only as `{canonical}`");
257    }
258    if ODATA_SYSTEM_QUERY_OPTIONS.contains(&canonical.as_str()) {
259        return format!(
260            "unsupported OData system query option `{option}`; {}",
261            unsupported_option_hint(&canonical)
262        );
263    }
264    format!(
265        "unknown query option `{option}`; the `$` prefix is reserved for \
266         OData system query options"
267    )
268}
269
270/// Reject every `$`-prefixed query key this extractor does not bind.
271///
272/// Required by OASIS `OData` 4.01 Part 1: Protocol, §6.1 "Query Option
273/// Extensibility" — see [`ACCEPTED_SYSTEM_QUERY_OPTIONS`]. Without this,
274/// axum drops unclaimed query keys and the caller gets `200` with a result
275/// set that ignored what they asked for: `?$skip=20` re-reads page one,
276/// `?$filtre=…` returns the whole unfiltered collection.
277///
278/// Every offending option is reported in one response so a caller fixes
279/// them in one round trip.
280///
281/// # Errors
282/// Returns a canonical `InvalidArgument` (`400`) carrying one
283/// `UNSUPPORTED_QUERY_PARAM` field violation per offending key, in wire
284/// order.
285#[allow(clippy::result_large_err)]
286fn reject_unsupported_system_query_options(
287    pairs: &[(String, String)],
288) -> Result<(), CanonicalError> {
289    // Deduplicated in wire order: a repeated option is one mistake to fix,
290    // and the offender list is bounded by the query string, so a linear
291    // `contains` is cheaper than a set.
292    let mut offending: Vec<&str> = Vec::new();
293    for key in pairs.iter().map(|(key, _)| key.as_str()) {
294        if key.starts_with('$')
295            && !ACCEPTED_SYSTEM_QUERY_OPTIONS.contains(&key)
296            && !offending.contains(&key)
297        {
298            offending.push(key);
299        }
300    }
301
302    let mut offenders = offending.into_iter();
303    let Some(first) = offenders.next() else {
304        return Ok(());
305    };
306
307    let mut violations = OdataError::invalid_argument().with_field_violation(
308        first,
309        unsupported_option_detail(first),
310        UNSUPPORTED_QUERY_PARAM,
311    );
312    for option in offenders {
313        violations = violations.with_field_violation(
314            option,
315            unsupported_option_detail(option),
316            UNSUPPORTED_QUERY_PARAM,
317        );
318    }
319
320    Err(violations.create())
321}
322
323/// Extract and validate full `OData` query from request parts.
324/// - Rejects any `$`-prefixed key outside [`ACCEPTED_SYSTEM_QUERY_OPTIONS`]
325/// - Parses $filter, $orderby, $top / limit, $skiptoken / cursor
326/// - Enforces budgets and validates formats
327/// - Returns unified `ODataQuery`
328///
329/// # Errors
330/// Returns a `CanonicalError` if any `OData` parameter is invalid. Axum
331/// renders it as `application/problem+json` via `IntoResponse for
332/// CanonicalError`; `canonical_error_middleware` fills `instance` /
333/// `trace_id` on the way out.
334pub async fn extract_odata_query<S>(
335    parts: &mut Parts,
336    state: &S,
337) -> Result<ODataQuery, CanonicalError>
338where
339    S: Send + Sync,
340{
341    // Runs before any value parsing: a request naming an option this
342    // extractor does not bind is malformed whatever the other values say.
343    // Deserializing the raw pairs first keeps percent-decoding identical to
344    // what `Query::<ODataParams>` binds below.
345    let Query(pairs) = Query::<Vec<(String, String)>>::from_request_parts(parts, state)
346        .await
347        .map_err(|e| query_params_invalid_arg(format!("Invalid query parameters: {e}")))?;
348    reject_unsupported_system_query_options(&pairs)?;
349
350    let Query(params) = Query::<ODataParams>::from_request_parts(parts, state)
351        .await
352        .map_err(|e| query_params_invalid_arg(format!("Invalid query parameters: {e}")))?;
353
354    let mut query = ODataQuery::new();
355
356    // Parse filter
357    if let Some(raw_filter) = params.filter.as_ref() {
358        let raw = raw_filter.trim();
359        if !raw.is_empty() {
360            if raw.len() > MAX_FILTER_LEN {
361                return Err(filter_invalid_arg("Filter too long", "FILTER_TOO_LONG"));
362            }
363
364            // Parse filter string using toolkit-odata
365            let parsed = toolkit_odata::parse_filter_string(raw).map_err(|e| {
366                // Length-only debug log; the canonical's `diagnostic()` carries
367                // the actual parser cause for `canonical_error_middleware`.
368                tracing::debug!(error = %e, filter_len = raw.len(), "OData filter parsing failed");
369                CanonicalError::from(e)
370            })?;
371
372            if parsed.node_count() > MAX_NODES {
373                tracing::debug!(
374                    node_count = parsed.node_count(),
375                    max_nodes = MAX_NODES,
376                    "Filter complexity budget exceeded"
377                );
378                return Err(filter_invalid_arg(
379                    "Filter too complex",
380                    "FILTER_TOO_COMPLEX",
381                ));
382            }
383
384            // Generate filter hash for cursor consistency (use non-consuming accessor)
385            let filter_hash = toolkit_odata::pagination::short_filter_hash(Some(parsed.as_expr()));
386
387            // Extract expression for query
388            let core_expr = parsed.into_expr();
389
390            query = query.with_filter(core_expr);
391            if let Some(hash) = filter_hash {
392                query = query.with_filter_hash(hash);
393            }
394        }
395    }
396
397    // Check for cursor+orderby conflict before parsing either
398    if params.cursor.is_some() && params.orderby.is_some() {
399        return Err(ODataError::OrderWithCursor.into());
400    }
401
402    // Parse cursor first (if present, skip orderby)
403    if let Some(cursor_str) = params.cursor.as_ref() {
404        let cursor = CursorV1::decode(cursor_str).map_err(|_| ODataError::InvalidCursor)?;
405        query = query.with_cursor(cursor);
406        // When cursor is present, order is empty (derived from cursor.s later)
407        query = query.with_order(ODataOrderBy::empty());
408    } else if let Some(raw_orderby) = params.orderby.as_ref() {
409        // Parse orderby only when cursor is absent
410        let order = parse_orderby(raw_orderby).map_err(CanonicalError::from)?;
411        query = query.with_order(order);
412    }
413
414    // Parse limit
415    if let Some(limit) = params.limit {
416        if limit == 0 {
417            return Err(ODataError::InvalidLimit.into());
418        }
419        query = query.with_limit(limit);
420    }
421
422    // Parse select
423    if let Some(raw_select) = params.select.as_ref() {
424        let fields = parse_select(raw_select)?;
425        query = query.with_select(fields);
426    }
427
428    Ok(query)
429}
430
431use std::ops::Deref;
432
433/// Simple Axum extractor for full `OData` query parameters.
434/// Parses $filter, $orderby, limit, and cursor parameters.
435/// Usage in handlers:
436///   async fn `list_users(OData(query)`: `OData`, /* ... */) { /* use `query` */ }
437#[derive(Debug, Clone)]
438pub struct OData(pub ODataQuery);
439
440impl OData {
441    #[inline]
442    pub fn into_inner(self) -> ODataQuery {
443        self.0
444    }
445}
446
447impl Deref for OData {
448    type Target = ODataQuery;
449    #[inline]
450    fn deref(&self) -> &Self::Target {
451        &self.0
452    }
453}
454
455impl AsRef<ODataQuery> for OData {
456    #[inline]
457    fn as_ref(&self) -> &ODataQuery {
458        &self.0
459    }
460}
461
462impl From<OData> for ODataQuery {
463    #[inline]
464    fn from(x: OData) -> Self {
465        x.0
466    }
467}
468
469impl<S> FromRequestParts<S> for OData
470where
471    S: Send + Sync,
472{
473    type Rejection = CanonicalError;
474
475    #[allow(clippy::manual_async_fn)]
476    fn from_request_parts(
477        parts: &mut Parts,
478        state: &S,
479    ) -> impl core::future::Future<Output = Result<Self, Self::Rejection>> + Send {
480        async move {
481            let query = extract_odata_query(parts, state).await?;
482            Ok(OData(query))
483        }
484    }
485}
486
487#[cfg(test)]
488#[cfg_attr(coverage_nightly, coverage(off))]
489#[path = "odata_tests.rs"]
490mod odata_tests;