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// FilterOp + FilterParams
134// ---------------------------------------------------------------------------
135
136/// Filter comparison operator. Closed set — wire and Rust agree on the
137/// vocabulary so per-service operator dialects ("eq" vs "equal" vs "==")
138/// can't drift.
139///
140/// Mirrors `bones.v1.FilterOp` in `proto/bones/v1/queries.proto`.
141#[cfg(any(feature = "std", feature = "alloc"))]
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143#[cfg_attr(
144    feature = "serde",
145    derive(Serialize, Deserialize),
146    serde(rename_all = "snake_case")
147)]
148#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
150#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
151#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
152pub enum FilterOp {
153    /// `field == value`.
154    Eq,
155    /// `field != value`.
156    Neq,
157    /// `field > value`.
158    Gt,
159    /// `field >= value`.
160    Gte,
161    /// `field < value`.
162    Lt,
163    /// `field <= value`.
164    Lte,
165    /// Membership; `value` carries a server-defined separator (default ',').
166    In,
167    /// Non-membership; `value` carries a separator-joined list.
168    NotIn,
169    /// Substring match on string fields.
170    Contains,
171    /// Prefix match on string fields.
172    StartsWith,
173    /// Suffix match on string fields.
174    EndsWith,
175    /// Field is present; `value` ignored.
176    Exists,
177    /// Field is absent; `value` ignored.
178    NotExists,
179}
180
181/// A single filter triple: `field`, `op`, `value`.
182///
183/// ```json
184/// {"field": "status", "op": "eq", "value": "active"}
185/// ```
186#[cfg(any(feature = "std", feature = "alloc"))]
187#[derive(Debug, Clone, PartialEq, Eq)]
188#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
189#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
192#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
193pub struct FilterEntry {
194    /// The field name to filter on.
195    pub field: String,
196    /// The comparison operator. Closed enum — services cannot accept
197    /// off-list operators.
198    pub op: FilterOp,
199    /// The value to compare against. For `In` / `NotIn` this is a
200    /// separator-joined list; for `Exists` / `NotExists` it is ignored.
201    pub value: String,
202}
203
204#[cfg(any(feature = "std", feature = "alloc"))]
205impl FilterEntry {
206    /// Create a filter entry.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use api_bones::query::{FilterEntry, FilterOp};
212    ///
213    /// let entry = FilterEntry::new("status", FilterOp::Eq, "active");
214    /// assert_eq!(entry.field, "status");
215    /// assert_eq!(entry.op, FilterOp::Eq);
216    /// assert_eq!(entry.value, "active");
217    /// ```
218    #[must_use]
219    pub fn new(field: impl Into<String>, op: FilterOp, value: impl Into<String>) -> Self {
220        Self {
221            field: field.into(),
222            op,
223            value: value.into(),
224        }
225    }
226}
227
228/// Query parameters for structured filtering on a collection endpoint.
229///
230/// Each entry is a field/operator/value triple. Multiple entries are
231/// AND-combined by convention; consumers may choose different semantics.
232///
233/// ```json
234/// {"filters": [{"field": "status", "op": "eq", "value": "active"}]}
235/// ```
236#[cfg(any(feature = "std", feature = "alloc"))]
237#[derive(Debug, Clone, PartialEq, Eq, Default)]
238#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
239#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
240#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
241#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
242#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
243pub struct FilterParams {
244    /// The list of filter entries.
245    #[cfg_attr(feature = "serde", serde(default))]
246    pub filters: Vec<FilterEntry>,
247}
248
249#[cfg(any(feature = "std", feature = "alloc"))]
250impl FilterParams {
251    /// Create filter params from an iterator of entries.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use api_bones::query::{FilterParams, FilterEntry, FilterOp};
257    ///
258    /// let f = FilterParams::new([FilterEntry::new("status", FilterOp::Eq, "active")]);
259    /// assert!(!f.is_empty());
260    /// assert_eq!(f.filters.len(), 1);
261    /// ```
262    #[must_use]
263    pub fn new(filters: impl IntoIterator<Item = FilterEntry>) -> Self {
264        Self {
265            filters: filters.into_iter().collect(),
266        }
267    }
268
269    /// Returns `true` if no filters are set.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use api_bones::query::FilterParams;
275    ///
276    /// let f = FilterParams::default();
277    /// assert!(f.is_empty());
278    /// ```
279    #[must_use]
280    pub fn is_empty(&self) -> bool {
281        self.filters.is_empty()
282    }
283}
284
285// ---------------------------------------------------------------------------
286// SearchParams
287// ---------------------------------------------------------------------------
288
289/// Query parameters for full-text search on a collection endpoint.
290///
291/// `query` is the search string. `fields` optionally scopes the search
292/// to specific fields; when omitted the backend decides which fields to search.
293///
294/// ```json
295/// {"query": "annual report", "fields": ["title", "description"]}
296/// ```
297#[cfg(any(feature = "std", feature = "alloc"))]
298#[derive(Debug, Clone, PartialEq, Eq)]
299#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
300#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302#[cfg_attr(feature = "validator", derive(Validate))]
303#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
304pub struct SearchParams {
305    /// The search string. Must not exceed 500 characters.
306    #[cfg_attr(
307        feature = "validator",
308        validate(length(
309            min = 1,
310            max = 500,
311            message = "query must be between 1 and 500 characters"
312        ))
313    )]
314    #[cfg_attr(feature = "proptest", proptest(strategy = "search_query_strategy()"))]
315    pub query: String,
316    /// Optional list of field names to scope the search to.
317    #[cfg_attr(
318        feature = "serde",
319        serde(default, skip_serializing_if = "Vec::is_empty")
320    )]
321    pub fields: Vec<String>,
322}
323
324#[cfg(any(feature = "std", feature = "alloc"))]
325impl SearchParams {
326    /// Create search params targeting all fields.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// use api_bones::query::SearchParams;
332    ///
333    /// let s = SearchParams::new("annual report");
334    /// assert_eq!(s.query, "annual report");
335    /// assert!(s.fields.is_empty());
336    /// ```
337    #[must_use]
338    pub fn new(query: impl Into<String>) -> Self {
339        Self {
340            query: query.into(),
341            fields: Vec::new(),
342        }
343    }
344
345    /// Create validated search params — enforces 1–500 char constraint without `validator` feature.
346    ///
347    /// # Examples
348    ///
349    /// ```
350    /// use api_bones::query::SearchParams;
351    ///
352    /// let s = SearchParams::try_new("annual report").unwrap();
353    /// assert_eq!(s.query, "annual report");
354    ///
355    /// assert!(SearchParams::try_new("").is_err());
356    /// assert!(SearchParams::try_new("a".repeat(501)).is_err());
357    /// ```
358    pub fn try_new(query: impl Into<String>) -> Result<Self, crate::error::ValidationError> {
359        let query = query.into();
360        if query.is_empty() || query.len() > 500 {
361            return Err(crate::error::ValidationError {
362                field: "/query".into(),
363                message: "must be between 1 and 500 characters".into(),
364                rule: Some("length".into()),
365            });
366        }
367        Ok(Self {
368            query,
369            fields: Vec::new(),
370        })
371    }
372
373    /// Create validated search params scoped to specific fields.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use api_bones::query::SearchParams;
379    ///
380    /// let s = SearchParams::try_with_fields("report", ["title"]).unwrap();
381    /// assert_eq!(s.fields, vec!["title"]);
382    ///
383    /// assert!(SearchParams::try_with_fields("", ["title"]).is_err());
384    /// ```
385    pub fn try_with_fields(
386        query: impl Into<String>,
387        fields: impl IntoIterator<Item = impl Into<String>>,
388    ) -> Result<Self, crate::error::ValidationError> {
389        let mut s = Self::try_new(query)?;
390        s.fields = fields.into_iter().map(Into::into).collect();
391        Ok(s)
392    }
393
394    /// Create search params scoped to specific fields.
395    ///
396    /// # Examples
397    ///
398    /// ```
399    /// use api_bones::query::SearchParams;
400    ///
401    /// let s = SearchParams::with_fields("report", ["title", "description"]);
402    /// assert_eq!(s.query, "report");
403    /// assert_eq!(s.fields, vec!["title", "description"]);
404    /// ```
405    #[must_use]
406    pub fn with_fields(
407        query: impl Into<String>,
408        fields: impl IntoIterator<Item = impl Into<String>>,
409    ) -> Self {
410        Self {
411            query: query.into(),
412            fields: fields.into_iter().map(Into::into).collect(),
413        }
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Axum extractors — `axum` feature
419// ---------------------------------------------------------------------------
420
421#[cfg(feature = "axum")]
422#[allow(clippy::result_large_err)]
423mod axum_extractors {
424    use super::SortParams;
425    use crate::error::ApiError;
426    use axum::extract::{FromRequestParts, Query};
427    use axum::http::request::Parts;
428
429    impl<S: Send + Sync> FromRequestParts<S> for SortParams {
430        type Rejection = ApiError;
431
432        async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
433            let Query(params) = Query::<Self>::from_request_parts(parts, state)
434                .await
435                .map_err(|e| ApiError::bad_request(e.to_string()))?;
436            Ok(params)
437        }
438    }
439}
440
441// ---------------------------------------------------------------------------
442// proptest strategy helpers
443// ---------------------------------------------------------------------------
444
445#[cfg(all(feature = "proptest", any(feature = "std", feature = "alloc")))]
446fn search_query_strategy() -> impl proptest::strategy::Strategy<Value = String> {
447    proptest::string::string_regex("[a-zA-Z0-9 ]{1,500}").expect("valid regex")
448}
449
450// ---------------------------------------------------------------------------
451// arbitrary::Arbitrary manual impl — constrained SearchParams
452// ---------------------------------------------------------------------------
453
454#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
455impl<'a> arbitrary::Arbitrary<'a> for SearchParams {
456    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
457        // Generate a query length between 1 and 500, then fill with arbitrary bytes
458        // mapped to printable ASCII (32–126) to satisfy the validator constraint.
459        let len = u.int_in_range(1usize..=500)?;
460        let query: String = (0..len)
461            .map(|_| -> arbitrary::Result<char> {
462                let byte = u.int_in_range(32u8..=126)?;
463                Ok(char::from(byte))
464            })
465            .collect::<arbitrary::Result<_>>()?;
466        let fields = <Vec<String> as arbitrary::Arbitrary>::arbitrary(u)?;
467        Ok(Self { query, fields })
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Tests
473// ---------------------------------------------------------------------------
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    // --- SortDirection ---
480
481    #[test]
482    fn sort_direction_default_is_asc() {
483        assert_eq!(SortDirection::default(), SortDirection::Asc);
484    }
485
486    #[cfg(feature = "serde")]
487    #[test]
488    fn sort_direction_serde_lowercase() {
489        let asc = serde_json::to_value(SortDirection::Asc).unwrap();
490        assert_eq!(asc, serde_json::json!("asc"));
491        let desc = serde_json::to_value(SortDirection::Desc).unwrap();
492        assert_eq!(desc, serde_json::json!("desc"));
493
494        let back: SortDirection = serde_json::from_value(asc).unwrap();
495        assert_eq!(back, SortDirection::Asc);
496    }
497
498    // --- SortParams ---
499
500    #[test]
501    fn sort_params_asc_helper() {
502        let p = SortParams::asc("created_at");
503        assert_eq!(p.sort_by, "created_at");
504        assert_eq!(p.direction, SortDirection::Asc);
505    }
506
507    #[test]
508    fn sort_params_desc_helper() {
509        let p = SortParams::desc("name");
510        assert_eq!(p.sort_by, "name");
511        assert_eq!(p.direction, SortDirection::Desc);
512    }
513
514    #[cfg(feature = "serde")]
515    #[test]
516    fn sort_params_serde_round_trip() {
517        let p = SortParams::desc("created_at");
518        let json = serde_json::to_value(&p).unwrap();
519        assert_eq!(json["sort_by"], "created_at");
520        assert_eq!(json["direction"], "desc");
521        let back: SortParams = serde_json::from_value(json).unwrap();
522        assert_eq!(back, p);
523    }
524
525    #[cfg(feature = "serde")]
526    #[test]
527    fn sort_params_serde_default_direction() {
528        let json = serde_json::json!({"sort_by": "name"});
529        let p: SortParams = serde_json::from_value(json).unwrap();
530        assert_eq!(p.direction, SortDirection::Asc);
531    }
532
533    // --- FilterParams ---
534
535    #[test]
536    fn filter_params_default_is_empty() {
537        let f = FilterParams::default();
538        assert!(f.is_empty());
539    }
540
541    #[test]
542    fn filter_params_new() {
543        let f = FilterParams::new([FilterEntry::new("status", FilterOp::Eq, "active")]);
544        assert!(!f.is_empty());
545        assert_eq!(f.filters.len(), 1);
546        assert_eq!(f.filters[0].field, "status");
547        assert_eq!(f.filters[0].op, FilterOp::Eq);
548        assert_eq!(f.filters[0].value, "active");
549    }
550
551    #[cfg(feature = "serde")]
552    #[test]
553    fn filter_params_serde_round_trip() {
554        let f = FilterParams::new([FilterEntry::new("age", FilterOp::Gt, "18")]);
555        let json = serde_json::to_value(&f).unwrap();
556        let back: FilterParams = serde_json::from_value(json).unwrap();
557        assert_eq!(back, f);
558    }
559
560    #[cfg(feature = "serde")]
561    #[test]
562    fn filter_op_serde_snake_case() {
563        assert_eq!(
564            serde_json::to_value(FilterOp::Eq).unwrap(),
565            serde_json::json!("eq")
566        );
567        assert_eq!(
568            serde_json::to_value(FilterOp::Neq).unwrap(),
569            serde_json::json!("neq")
570        );
571        assert_eq!(
572            serde_json::to_value(FilterOp::StartsWith).unwrap(),
573            serde_json::json!("starts_with")
574        );
575        assert_eq!(
576            serde_json::to_value(FilterOp::NotExists).unwrap(),
577            serde_json::json!("not_exists")
578        );
579        let back: FilterOp = serde_json::from_value(serde_json::json!("gte")).unwrap();
580        assert_eq!(back, FilterOp::Gte);
581    }
582
583    #[cfg(feature = "serde")]
584    #[test]
585    fn filter_params_serde_empty_filters_default() {
586        let json = serde_json::json!({});
587        let f: FilterParams = serde_json::from_value(json).unwrap();
588        assert!(f.is_empty());
589    }
590
591    // --- SearchParams ---
592
593    #[test]
594    fn search_params_new() {
595        let s = SearchParams::new("annual report");
596        assert_eq!(s.query, "annual report");
597        assert!(s.fields.is_empty());
598    }
599
600    #[test]
601    fn search_params_with_fields() {
602        let s = SearchParams::with_fields("report", ["title", "description"]);
603        assert_eq!(s.query, "report");
604        assert_eq!(s.fields, vec!["title", "description"]);
605    }
606
607    #[cfg(feature = "serde")]
608    #[test]
609    fn search_params_serde_round_trip() {
610        let s = SearchParams::with_fields("hello", ["name"]);
611        let json = serde_json::to_value(&s).unwrap();
612        assert_eq!(json["query"], "hello");
613        assert_eq!(json["fields"], serde_json::json!(["name"]));
614        let back: SearchParams = serde_json::from_value(json).unwrap();
615        assert_eq!(back, s);
616    }
617
618    #[cfg(feature = "serde")]
619    #[test]
620    fn search_params_serde_omits_empty_fields() {
621        let s = SearchParams::new("test");
622        let json = serde_json::to_value(&s).unwrap();
623        assert!(json.get("fields").is_none());
624    }
625
626    #[cfg(feature = "validator")]
627    #[test]
628    fn search_params_validate_empty_query_fails() {
629        use validator::Validate;
630        let s = SearchParams::new("");
631        assert!(s.validate().is_err());
632    }
633
634    #[cfg(feature = "validator")]
635    #[test]
636    fn search_params_validate_too_long_fails() {
637        use validator::Validate;
638        let s = SearchParams::new("a".repeat(501));
639        assert!(s.validate().is_err());
640    }
641
642    #[cfg(feature = "validator")]
643    #[test]
644    fn search_params_validate_boundary_max() {
645        use validator::Validate;
646        let s = SearchParams::new("a".repeat(500));
647        assert!(s.validate().is_ok());
648    }
649
650    // -----------------------------------------------------------------------
651    // SearchParams::try_new / try_with_fields — fallible constructors
652    // -----------------------------------------------------------------------
653
654    #[test]
655    fn search_params_try_new_valid() {
656        let s = SearchParams::try_new("report").unwrap();
657        assert_eq!(s.query, "report");
658        assert!(s.fields.is_empty());
659    }
660
661    #[test]
662    fn search_params_try_new_boundary_min() {
663        assert!(SearchParams::try_new("a").is_ok());
664    }
665
666    #[test]
667    fn search_params_try_new_boundary_max() {
668        assert!(SearchParams::try_new("a".repeat(500)).is_ok());
669    }
670
671    #[test]
672    fn search_params_try_new_empty_fails() {
673        let err = SearchParams::try_new("").unwrap_err();
674        assert_eq!(err.field, "/query");
675        assert_eq!(err.rule.as_deref(), Some("length"));
676    }
677
678    #[test]
679    fn search_params_try_new_too_long_fails() {
680        assert!(SearchParams::try_new("a".repeat(501)).is_err());
681    }
682
683    #[test]
684    fn search_params_try_with_fields_valid() {
685        let s = SearchParams::try_with_fields("report", ["title", "body"]).unwrap();
686        assert_eq!(s.query, "report");
687        assert_eq!(s.fields, vec!["title", "body"]);
688    }
689
690    #[test]
691    fn search_params_try_with_fields_empty_query_fails() {
692        assert!(SearchParams::try_with_fields("", ["title"]).is_err());
693    }
694
695    #[cfg(feature = "axum")]
696    mod axum_extractor_tests {
697        use super::super::{SortDirection, SortParams};
698        use axum::extract::FromRequestParts;
699        use axum::http::Request;
700
701        async fn extract(q: &str) -> Result<SortParams, u16> {
702            let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
703            let (mut parts, ()) = req.into_parts();
704            SortParams::from_request_parts(&mut parts, &())
705                .await
706                .map_err(|e| e.status)
707        }
708
709        #[tokio::test]
710        async fn sort_default_direction() {
711            let p = extract("sort_by=name").await.unwrap();
712            assert_eq!(p.sort_by, "name");
713            assert_eq!(p.direction, SortDirection::Asc);
714        }
715
716        #[tokio::test]
717        async fn sort_custom_direction() {
718            let p = extract("sort_by=created_at&direction=desc").await.unwrap();
719            assert_eq!(p.direction, SortDirection::Desc);
720        }
721
722        #[tokio::test]
723        async fn sort_missing_sort_by_rejected() {
724            assert_eq!(extract("").await.unwrap_err(), 400);
725        }
726    }
727
728    #[cfg(feature = "validator")]
729    #[test]
730    fn search_params_validate_ok() {
731        use validator::Validate;
732        let s = SearchParams::new("valid query");
733        assert!(s.validate().is_ok());
734    }
735}