Skip to main content

api_bones/
query.rs

1//! Query parameter types for list endpoints.
2//!
3//! Provides reusable structs for sorting, filtering, and full-text search
4//! so consumers don't reinvent query parameter handling for every collection endpoint.
5//!
6//! # Overview
7//!
8//! - [`SortDirection`] — ascending or descending order
9//! - [`SortParams`] — field name + direction
10//! - [`FilterParams`] — field/operator/value triples for structured filtering
11//! - [`SearchParams`] — full-text query with optional field scoping
12
13#[cfg(all(not(feature = "std"), feature = "alloc"))]
14use alloc::{string::String, vec::Vec};
15#[cfg(feature = "serde")]
16use serde::{Deserialize, Serialize};
17#[cfg(all(feature = "validator", any(feature = "std", feature = "alloc")))]
18use validator::Validate;
19
20// ---------------------------------------------------------------------------
21// SortDirection
22// ---------------------------------------------------------------------------
23
24/// Sort order for list endpoints.
25///
26/// # Examples
27///
28/// ```
29/// use api_bones::query::SortDirection;
30///
31/// assert_eq!(SortDirection::default(), SortDirection::Asc);
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34#[cfg_attr(
35    feature = "serde",
36    derive(Serialize, Deserialize),
37    serde(rename_all = "lowercase")
38)]
39#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
42#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
43pub enum SortDirection {
44    /// Ascending order (A → Z, 0 → 9).
45    #[default]
46    Asc,
47    /// Descending order (Z → A, 9 → 0).
48    Desc,
49}
50
51// ---------------------------------------------------------------------------
52// SortParams
53// ---------------------------------------------------------------------------
54
55#[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
56fn default_sort_direction() -> SortDirection {
57    SortDirection::Asc
58}
59
60/// Query parameters for sorting a collection endpoint.
61///
62/// ```json
63/// {"sort_by": "created_at", "direction": "desc"}
64/// ```
65#[cfg(any(feature = "std", feature = "alloc"))]
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
68#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
69#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
70#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
71#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
72pub struct SortParams {
73    /// The field name to sort by (e.g. `"created_at"`, `"name"`).
74    pub sort_by: String,
75    /// Sort direction. Defaults to [`SortDirection::Asc`].
76    #[cfg_attr(feature = "serde", serde(default = "default_sort_direction"))]
77    pub direction: SortDirection,
78}
79
80#[cfg(any(feature = "std", feature = "alloc"))]
81impl SortParams {
82    /// Create sort params with the given field and direction.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use api_bones::query::{SortParams, SortDirection};
88    ///
89    /// let p = SortParams::new("created_at", SortDirection::Desc);
90    /// assert_eq!(p.sort_by, "created_at");
91    /// assert_eq!(p.direction, SortDirection::Desc);
92    /// ```
93    #[must_use]
94    pub fn new(sort_by: impl Into<String>, direction: SortDirection) -> Self {
95        Self {
96            sort_by: sort_by.into(),
97            direction,
98        }
99    }
100
101    /// Create sort params with ascending direction.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use api_bones::query::{SortParams, SortDirection};
107    ///
108    /// let p = SortParams::asc("name");
109    /// assert_eq!(p.direction, SortDirection::Asc);
110    /// ```
111    #[must_use]
112    pub fn asc(sort_by: impl Into<String>) -> Self {
113        Self::new(sort_by, SortDirection::Asc)
114    }
115
116    /// Create sort params with descending direction.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use api_bones::query::{SortParams, SortDirection};
122    ///
123    /// let p = SortParams::desc("created_at");
124    /// assert_eq!(p.direction, SortDirection::Desc);
125    /// ```
126    #[must_use]
127    pub fn desc(sort_by: impl Into<String>) -> Self {
128        Self::new(sort_by, SortDirection::Desc)
129    }
130}
131
132// ---------------------------------------------------------------------------
133// FilterParams
134// ---------------------------------------------------------------------------
135
136/// A single filter triple: `field`, `operator`, `value`.
137///
138/// ```json
139/// {"field": "status", "operator": "eq", "value": "active"}
140/// ```
141#[cfg(any(feature = "std", feature = "alloc"))]
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
144#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
146#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
147#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
148pub struct FilterEntry {
149    /// The field name to filter on.
150    pub field: String,
151    /// The comparison operator (e.g. `"eq"`, `"neq"`, `"gt"`, `"lt"`, `"contains"`).
152    pub operator: String,
153    /// The value to compare against.
154    pub value: String,
155}
156
157#[cfg(any(feature = "std", feature = "alloc"))]
158impl FilterEntry {
159    /// Create a filter entry.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use api_bones::query::FilterEntry;
165    ///
166    /// let entry = FilterEntry::new("status", "eq", "active");
167    /// assert_eq!(entry.field, "status");
168    /// assert_eq!(entry.operator, "eq");
169    /// assert_eq!(entry.value, "active");
170    /// ```
171    #[must_use]
172    pub fn new(
173        field: impl Into<String>,
174        operator: impl Into<String>,
175        value: impl Into<String>,
176    ) -> Self {
177        Self {
178            field: field.into(),
179            operator: operator.into(),
180            value: value.into(),
181        }
182    }
183}
184
185/// Query parameters for structured filtering on a collection endpoint.
186///
187/// Each entry is a field/operator/value triple. Multiple entries are
188/// AND-combined by convention; consumers may choose different semantics.
189///
190/// ```json
191/// {"filters": [{"field": "status", "operator": "eq", "value": "active"}]}
192/// ```
193#[cfg(any(feature = "std", feature = "alloc"))]
194#[derive(Debug, Clone, PartialEq, Eq, Default)]
195#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
196#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
198#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
199#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
200pub struct FilterParams {
201    /// The list of filter entries.
202    #[cfg_attr(feature = "serde", serde(default))]
203    pub filters: Vec<FilterEntry>,
204}
205
206#[cfg(any(feature = "std", feature = "alloc"))]
207impl FilterParams {
208    /// Create filter params from an iterator of entries.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use api_bones::query::{FilterParams, FilterEntry};
214    ///
215    /// let f = FilterParams::new([FilterEntry::new("status", "eq", "active")]);
216    /// assert!(!f.is_empty());
217    /// assert_eq!(f.filters.len(), 1);
218    /// ```
219    #[must_use]
220    pub fn new(filters: impl IntoIterator<Item = FilterEntry>) -> Self {
221        Self {
222            filters: filters.into_iter().collect(),
223        }
224    }
225
226    /// Returns `true` if no filters are set.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use api_bones::query::FilterParams;
232    ///
233    /// let f = FilterParams::default();
234    /// assert!(f.is_empty());
235    /// ```
236    #[must_use]
237    pub fn is_empty(&self) -> bool {
238        self.filters.is_empty()
239    }
240}
241
242// ---------------------------------------------------------------------------
243// SearchParams
244// ---------------------------------------------------------------------------
245
246/// Query parameters for full-text search on a collection endpoint.
247///
248/// `query` is the search string. `fields` optionally scopes the search
249/// to specific fields; when omitted the backend decides which fields to search.
250///
251/// ```json
252/// {"query": "annual report", "fields": ["title", "description"]}
253/// ```
254#[cfg(any(feature = "std", feature = "alloc"))]
255#[derive(Debug, Clone, PartialEq, Eq)]
256#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
257#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
259#[cfg_attr(feature = "validator", derive(Validate))]
260#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
261pub struct SearchParams {
262    /// The search string. Must not exceed 500 characters.
263    #[cfg_attr(
264        feature = "validator",
265        validate(length(
266            min = 1,
267            max = 500,
268            message = "query must be between 1 and 500 characters"
269        ))
270    )]
271    #[cfg_attr(feature = "proptest", proptest(strategy = "search_query_strategy()"))]
272    pub query: String,
273    /// Optional list of field names to scope the search to.
274    #[cfg_attr(
275        feature = "serde",
276        serde(default, skip_serializing_if = "Vec::is_empty")
277    )]
278    pub fields: Vec<String>,
279}
280
281#[cfg(any(feature = "std", feature = "alloc"))]
282impl SearchParams {
283    /// Create search params targeting all fields.
284    ///
285    /// # Examples
286    ///
287    /// ```
288    /// use api_bones::query::SearchParams;
289    ///
290    /// let s = SearchParams::new("annual report");
291    /// assert_eq!(s.query, "annual report");
292    /// assert!(s.fields.is_empty());
293    /// ```
294    #[must_use]
295    pub fn new(query: impl Into<String>) -> Self {
296        Self {
297            query: query.into(),
298            fields: Vec::new(),
299        }
300    }
301
302    /// Create validated search params — enforces 1–500 char constraint without `validator` feature.
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// use api_bones::query::SearchParams;
308    ///
309    /// let s = SearchParams::try_new("annual report").unwrap();
310    /// assert_eq!(s.query, "annual report");
311    ///
312    /// assert!(SearchParams::try_new("").is_err());
313    /// assert!(SearchParams::try_new("a".repeat(501)).is_err());
314    /// ```
315    pub fn try_new(query: impl Into<String>) -> Result<Self, crate::error::ValidationError> {
316        let query = query.into();
317        if query.is_empty() || query.len() > 500 {
318            return Err(crate::error::ValidationError {
319                field: "/query".into(),
320                message: "must be between 1 and 500 characters".into(),
321                rule: Some("length".into()),
322            });
323        }
324        Ok(Self {
325            query,
326            fields: Vec::new(),
327        })
328    }
329
330    /// Create validated search params scoped to specific fields.
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use api_bones::query::SearchParams;
336    ///
337    /// let s = SearchParams::try_with_fields("report", ["title"]).unwrap();
338    /// assert_eq!(s.fields, vec!["title"]);
339    ///
340    /// assert!(SearchParams::try_with_fields("", ["title"]).is_err());
341    /// ```
342    pub fn try_with_fields(
343        query: impl Into<String>,
344        fields: impl IntoIterator<Item = impl Into<String>>,
345    ) -> Result<Self, crate::error::ValidationError> {
346        let mut s = Self::try_new(query)?;
347        s.fields = fields.into_iter().map(Into::into).collect();
348        Ok(s)
349    }
350
351    /// Create search params scoped to specific fields.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use api_bones::query::SearchParams;
357    ///
358    /// let s = SearchParams::with_fields("report", ["title", "description"]);
359    /// assert_eq!(s.query, "report");
360    /// assert_eq!(s.fields, vec!["title", "description"]);
361    /// ```
362    #[must_use]
363    pub fn with_fields(
364        query: impl Into<String>,
365        fields: impl IntoIterator<Item = impl Into<String>>,
366    ) -> Self {
367        Self {
368            query: query.into(),
369            fields: fields.into_iter().map(Into::into).collect(),
370        }
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Axum extractors — `axum` feature
376// ---------------------------------------------------------------------------
377
378#[cfg(feature = "axum")]
379#[allow(clippy::result_large_err)]
380mod axum_extractors {
381    use super::SortParams;
382    use crate::error::ApiError;
383    use axum::extract::{FromRequestParts, Query};
384    use axum::http::request::Parts;
385
386    impl<S: Send + Sync> FromRequestParts<S> for SortParams {
387        type Rejection = ApiError;
388
389        async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
390            let Query(params) = Query::<Self>::from_request_parts(parts, state)
391                .await
392                .map_err(|e| ApiError::bad_request(e.to_string()))?;
393            Ok(params)
394        }
395    }
396}
397
398// ---------------------------------------------------------------------------
399// proptest strategy helpers
400// ---------------------------------------------------------------------------
401
402#[cfg(all(feature = "proptest", any(feature = "std", feature = "alloc")))]
403fn search_query_strategy() -> impl proptest::strategy::Strategy<Value = String> {
404    proptest::string::string_regex("[a-zA-Z0-9 ]{1,500}").expect("valid regex")
405}
406
407// ---------------------------------------------------------------------------
408// arbitrary::Arbitrary manual impl — constrained SearchParams
409// ---------------------------------------------------------------------------
410
411#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
412impl<'a> arbitrary::Arbitrary<'a> for SearchParams {
413    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
414        // Generate a query length between 1 and 500, then fill with arbitrary bytes
415        // mapped to printable ASCII (32–126) to satisfy the validator constraint.
416        let len = u.int_in_range(1usize..=500)?;
417        let query: String = (0..len)
418            .map(|_| -> arbitrary::Result<char> {
419                let byte = u.int_in_range(32u8..=126)?;
420                Ok(char::from(byte))
421            })
422            .collect::<arbitrary::Result<_>>()?;
423        let fields = <Vec<String> as arbitrary::Arbitrary>::arbitrary(u)?;
424        Ok(Self { query, fields })
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Tests
430// ---------------------------------------------------------------------------
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    // --- SortDirection ---
437
438    #[test]
439    fn sort_direction_default_is_asc() {
440        assert_eq!(SortDirection::default(), SortDirection::Asc);
441    }
442
443    #[cfg(feature = "serde")]
444    #[test]
445    fn sort_direction_serde_lowercase() {
446        let asc = serde_json::to_value(SortDirection::Asc).unwrap();
447        assert_eq!(asc, serde_json::json!("asc"));
448        let desc = serde_json::to_value(SortDirection::Desc).unwrap();
449        assert_eq!(desc, serde_json::json!("desc"));
450
451        let back: SortDirection = serde_json::from_value(asc).unwrap();
452        assert_eq!(back, SortDirection::Asc);
453    }
454
455    // --- SortParams ---
456
457    #[test]
458    fn sort_params_asc_helper() {
459        let p = SortParams::asc("created_at");
460        assert_eq!(p.sort_by, "created_at");
461        assert_eq!(p.direction, SortDirection::Asc);
462    }
463
464    #[test]
465    fn sort_params_desc_helper() {
466        let p = SortParams::desc("name");
467        assert_eq!(p.sort_by, "name");
468        assert_eq!(p.direction, SortDirection::Desc);
469    }
470
471    #[cfg(feature = "serde")]
472    #[test]
473    fn sort_params_serde_round_trip() {
474        let p = SortParams::desc("created_at");
475        let json = serde_json::to_value(&p).unwrap();
476        assert_eq!(json["sort_by"], "created_at");
477        assert_eq!(json["direction"], "desc");
478        let back: SortParams = serde_json::from_value(json).unwrap();
479        assert_eq!(back, p);
480    }
481
482    #[cfg(feature = "serde")]
483    #[test]
484    fn sort_params_serde_default_direction() {
485        let json = serde_json::json!({"sort_by": "name"});
486        let p: SortParams = serde_json::from_value(json).unwrap();
487        assert_eq!(p.direction, SortDirection::Asc);
488    }
489
490    // --- FilterParams ---
491
492    #[test]
493    fn filter_params_default_is_empty() {
494        let f = FilterParams::default();
495        assert!(f.is_empty());
496    }
497
498    #[test]
499    fn filter_params_new() {
500        let f = FilterParams::new([FilterEntry::new("status", "eq", "active")]);
501        assert!(!f.is_empty());
502        assert_eq!(f.filters.len(), 1);
503        assert_eq!(f.filters[0].field, "status");
504        assert_eq!(f.filters[0].operator, "eq");
505        assert_eq!(f.filters[0].value, "active");
506    }
507
508    #[cfg(feature = "serde")]
509    #[test]
510    fn filter_params_serde_round_trip() {
511        let f = FilterParams::new([FilterEntry::new("age", "gt", "18")]);
512        let json = serde_json::to_value(&f).unwrap();
513        let back: FilterParams = serde_json::from_value(json).unwrap();
514        assert_eq!(back, f);
515    }
516
517    #[cfg(feature = "serde")]
518    #[test]
519    fn filter_params_serde_empty_filters_default() {
520        let json = serde_json::json!({});
521        let f: FilterParams = serde_json::from_value(json).unwrap();
522        assert!(f.is_empty());
523    }
524
525    // --- SearchParams ---
526
527    #[test]
528    fn search_params_new() {
529        let s = SearchParams::new("annual report");
530        assert_eq!(s.query, "annual report");
531        assert!(s.fields.is_empty());
532    }
533
534    #[test]
535    fn search_params_with_fields() {
536        let s = SearchParams::with_fields("report", ["title", "description"]);
537        assert_eq!(s.query, "report");
538        assert_eq!(s.fields, vec!["title", "description"]);
539    }
540
541    #[cfg(feature = "serde")]
542    #[test]
543    fn search_params_serde_round_trip() {
544        let s = SearchParams::with_fields("hello", ["name"]);
545        let json = serde_json::to_value(&s).unwrap();
546        assert_eq!(json["query"], "hello");
547        assert_eq!(json["fields"], serde_json::json!(["name"]));
548        let back: SearchParams = serde_json::from_value(json).unwrap();
549        assert_eq!(back, s);
550    }
551
552    #[cfg(feature = "serde")]
553    #[test]
554    fn search_params_serde_omits_empty_fields() {
555        let s = SearchParams::new("test");
556        let json = serde_json::to_value(&s).unwrap();
557        assert!(json.get("fields").is_none());
558    }
559
560    #[cfg(feature = "validator")]
561    #[test]
562    fn search_params_validate_empty_query_fails() {
563        use validator::Validate;
564        let s = SearchParams::new("");
565        assert!(s.validate().is_err());
566    }
567
568    #[cfg(feature = "validator")]
569    #[test]
570    fn search_params_validate_too_long_fails() {
571        use validator::Validate;
572        let s = SearchParams::new("a".repeat(501));
573        assert!(s.validate().is_err());
574    }
575
576    #[cfg(feature = "validator")]
577    #[test]
578    fn search_params_validate_boundary_max() {
579        use validator::Validate;
580        let s = SearchParams::new("a".repeat(500));
581        assert!(s.validate().is_ok());
582    }
583
584    // -----------------------------------------------------------------------
585    // SearchParams::try_new / try_with_fields — fallible constructors
586    // -----------------------------------------------------------------------
587
588    #[test]
589    fn search_params_try_new_valid() {
590        let s = SearchParams::try_new("report").unwrap();
591        assert_eq!(s.query, "report");
592        assert!(s.fields.is_empty());
593    }
594
595    #[test]
596    fn search_params_try_new_boundary_min() {
597        assert!(SearchParams::try_new("a").is_ok());
598    }
599
600    #[test]
601    fn search_params_try_new_boundary_max() {
602        assert!(SearchParams::try_new("a".repeat(500)).is_ok());
603    }
604
605    #[test]
606    fn search_params_try_new_empty_fails() {
607        let err = SearchParams::try_new("").unwrap_err();
608        assert_eq!(err.field, "/query");
609        assert_eq!(err.rule.as_deref(), Some("length"));
610    }
611
612    #[test]
613    fn search_params_try_new_too_long_fails() {
614        assert!(SearchParams::try_new("a".repeat(501)).is_err());
615    }
616
617    #[test]
618    fn search_params_try_with_fields_valid() {
619        let s = SearchParams::try_with_fields("report", ["title", "body"]).unwrap();
620        assert_eq!(s.query, "report");
621        assert_eq!(s.fields, vec!["title", "body"]);
622    }
623
624    #[test]
625    fn search_params_try_with_fields_empty_query_fails() {
626        assert!(SearchParams::try_with_fields("", ["title"]).is_err());
627    }
628
629    #[cfg(feature = "axum")]
630    mod axum_extractor_tests {
631        use super::super::{SortDirection, SortParams};
632        use axum::extract::FromRequestParts;
633        use axum::http::Request;
634
635        async fn extract(q: &str) -> Result<SortParams, u16> {
636            let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
637            let (mut parts, ()) = req.into_parts();
638            SortParams::from_request_parts(&mut parts, &())
639                .await
640                .map_err(|e| e.status)
641        }
642
643        #[tokio::test]
644        async fn sort_default_direction() {
645            let p = extract("sort_by=name").await.unwrap();
646            assert_eq!(p.sort_by, "name");
647            assert_eq!(p.direction, SortDirection::Asc);
648        }
649
650        #[tokio::test]
651        async fn sort_custom_direction() {
652            let p = extract("sort_by=created_at&direction=desc").await.unwrap();
653            assert_eq!(p.direction, SortDirection::Desc);
654        }
655
656        #[tokio::test]
657        async fn sort_missing_sort_by_rejected() {
658            assert_eq!(extract("").await.unwrap_err(), 400);
659        }
660    }
661
662    #[cfg(feature = "validator")]
663    #[test]
664    fn search_params_validate_ok() {
665        use validator::Validate;
666        let s = SearchParams::new("valid query");
667        assert!(s.validate().is_ok());
668    }
669}