Skip to main content

actix_cloud_extra/
api.rs

1//! REST API helpers: pagination parameters, dynamic query condition building
2//! and common request payloads.
3
4use std::cmp;
5#[cfg(feature = "seaorm")]
6use std::collections::BTreeMap;
7use std::hash::Hash;
8
9use actix_cloud::utils;
10use anyhow::Result;
11use derivative::Derivative;
12#[cfg(feature = "seaorm")]
13use sea_orm::{
14    ExprTrait as _, FromQueryResult, Order, QueryOrder as _,
15    entity::prelude::async_trait::async_trait,
16    prelude::*,
17    sea_query::{ColumnRef, LikeExpr, SimpleExpr},
18};
19use serde::{Deserialize, Serialize};
20use serde_inline_default::serde_inline_default;
21use validator::{Validate, ValidationError};
22
23use crate::HyUuid;
24#[cfg(feature = "entity")]
25use crate::entity::DefaultColumnTrait;
26#[cfg(feature = "seaorm")]
27use crate::utils::StringUtil as _;
28
29/// Standard paginated response: total item count plus one page of data.
30#[derive(Serialize, Debug)]
31pub struct PageData<T> {
32    /// Total number of items across all pages.
33    pub total: u64,
34    /// Items of the requested page.
35    pub data: Vec<T>,
36}
37
38impl<T> PageData<T> {
39    /// Build from a `(data, total)` tuple, e.g. as returned by
40    /// `Condition::select_page` or [`PaginationParam::split`].
41    pub fn new(data: (Vec<T>, u64)) -> Self {
42        Self {
43            total: data.1,
44            data: data.0,
45        }
46    }
47}
48
49/// Pagination parameters (`page` / `size`) accepted from query or body.
50///
51/// Both fields fall back to defaults (page 1, size 10) when missing. Call
52/// `validate` (from `validator`) to enforce `page >= 1` and
53/// `1 <= size <= 100`.
54#[serde_inline_default]
55#[derive(Derivative, Deserialize, Validate, Clone, Debug)]
56#[derivative(Default(new = "true"))]
57pub struct PaginationParam {
58    /// 1-based page index.
59    #[validate(range(min = 1))]
60    #[serde_inline_default(1)]
61    #[derivative(Default(value = "1"))]
62    pub page: u64,
63
64    /// Number of items per page, capped at 100 by validation.
65    #[validate(range(min = 1, max = 100))]
66    #[serde_inline_default(10)]
67    #[derivative(Default(value = "10"))]
68    pub size: u64,
69}
70
71impl PaginationParam {
72    /// 0-based index of the first item of the page (saturating).
73    pub fn left(&self) -> u64 {
74        self.page.saturating_sub(1).saturating_mul(self.size)
75    }
76
77    /// Exclusive 0-based end index of the page (saturating).
78    pub fn right(&self) -> u64 {
79        self.page.saturating_mul(self.size)
80    }
81
82    /// Clamp a `u64` offset/count down to [`usize`] so it can be used for
83    /// in-memory slicing on any platform.
84    pub fn round(p: u64) -> usize {
85        cmp::min(p, usize::MAX as u64) as usize
86    }
87
88    /// Apply the pagination to an in-memory list, returning the requested page
89    /// plus the total item count.
90    pub fn split<T>(&self, data: Vec<T>) -> PageData<T> {
91        let cnt = data.len();
92        PageData::new((
93            data.into_iter()
94                .skip(Self::round(self.left()))
95                .take(Self::round(self.size))
96                .collect(),
97            cnt as u64,
98        ))
99    }
100}
101
102/// Extension for SeaORM `Select`: execute a query with optional pagination and
103/// get back `(items, total_count)`.
104#[cfg(feature = "seaorm")]
105#[async_trait]
106pub trait SelectPage<E, C>
107where
108    E: EntityTrait,
109    C: ConnectionTrait,
110{
111    /// Fetch one page when `p` is given, or the whole result set (with its
112    /// length as total) otherwise.
113    async fn select_page(self, db: &C, p: Option<PaginationParam>) -> Result<(Vec<E::Model>, u64)>;
114}
115
116#[cfg(feature = "seaorm")]
117#[async_trait]
118impl<M, E, C> SelectPage<E, C> for Select<E>
119where
120    E: EntityTrait<Model = M>,
121    M: FromQueryResult + Sized + Send + Sync,
122    C: ConnectionTrait,
123{
124    async fn select_page(self, db: &C, p: Option<PaginationParam>) -> Result<(Vec<E::Model>, u64)> {
125        if let Some(page) = p {
126            let q = self.paginate(db, page.size);
127            Ok((
128                q.fetch_page(page.page.saturating_sub(1)).await?,
129                q.num_items().await?,
130            ))
131        } else {
132            let res = self.all(db).await?;
133            let cnt = res.len() as u64;
134            Ok((res, cnt))
135        }
136    }
137}
138
139/// Helpers to turn strings into SeaORM/SeaQuery expressions.
140#[cfg(feature = "seaorm")]
141pub trait IntoExpr {
142    /// Build `col LIKE '%<self>%'`, escaping `\`, `%` and `_` in `self`.
143    fn like_expr<T>(&self, col: T) -> SimpleExpr
144    where
145        T: ColumnTrait;
146}
147
148#[cfg(feature = "seaorm")]
149impl IntoExpr for &String {
150    fn like_expr<T>(&self, col: T) -> SimpleExpr
151    where
152        T: ColumnTrait,
153    {
154        sea_orm::prelude::Expr::col((col.entity_name(), col))
155            .like(LikeExpr::new(format!("%{}%", self.like_escape())).escape('\\'))
156    }
157}
158
159/// The query condition hub: filters + optional pagination + sort order.
160///
161/// Built fluently, then applied to a SeaORM `Select` via [`Condition::build`]
162/// or executed directly with [`Condition::select_page`]. All CRUD helpers
163/// generated by the `default_viewer` macro funnel through it.
164#[cfg(feature = "seaorm")]
165pub struct Condition {
166    /// Optional SQL pagination.
167    pub page: Option<PaginationParam>,
168    /// WHERE filters, `all()` by default.
169    pub cond: sea_orm::Condition,
170    /// Sort expressions, applied in insertion order.
171    pub order: Vec<(SimpleExpr, Order)>,
172}
173
174#[cfg(feature = "seaorm")]
175impl Default for Condition {
176    fn default() -> Self {
177        Self {
178            cond: sea_orm::Condition::all(),
179            page: None,
180            order: Vec::new(),
181        }
182    }
183}
184
185#[cfg(feature = "seaorm")]
186impl From<sea_orm::Condition> for Condition {
187    fn from(cond: sea_orm::Condition) -> Self {
188        Self::new(cond)
189    }
190}
191
192#[cfg(feature = "seaorm")]
193impl Condition {
194    /// Wrap an existing [`sea_orm::Condition`] as filters.
195    pub fn new(cond: sea_orm::Condition) -> Self {
196        Self {
197            cond,
198            page: None,
199            order: Vec::new(),
200        }
201    }
202
203    /// New condition matching everything (`all()`).
204    pub fn new_all() -> Self {
205        Self::new(Self::all())
206    }
207
208    /// New condition matching anything (`any()`).
209    pub fn new_any() -> Self {
210        Self::new(Self::any())
211    }
212
213    /// Append one sort expression; later calls take precedence for ties.
214    pub fn add_sort(mut self, col: SimpleExpr, sort: Order) -> Self {
215        self.order.push((col, sort));
216        self
217    }
218
219    /// Attach SQL pagination.
220    pub fn add_page(mut self, page: PaginationParam) -> Self {
221        self.page = Some(page);
222        self
223    }
224
225    /// Parse the sort string when present, see [`Condition::parse_sort`].
226    pub fn parse_sort_option(mut self, str: &Option<String>, col: Vec<ColumnRef>) -> Self {
227        if let Some(str) = str {
228            self = self.parse_sort(str, col)
229        }
230        self
231    }
232
233    /// Parse a sort string like `-created_at,+name` into sort expressions.
234    ///
235    /// `-`/`+` prefixes select descending/ascending order (no prefix =
236    /// ascending); names not present in the `col` whitelist are silently
237    /// ignored, so this is safe to feed straight from user input.
238    pub fn parse_sort(mut self, str: &str, col: Vec<ColumnRef>) -> Self {
239        let mut allow: BTreeMap<String, ColumnRef> = col
240            .into_iter()
241            .map(|x| (x.column().unwrap().to_string(), x))
242            .collect();
243        for i in str.split(',') {
244            let order = if i.starts_with('-') {
245                Order::Desc
246            } else {
247                Order::Asc
248            };
249            let name = i
250                .strip_prefix('+')
251                .or_else(|| i.strip_prefix('-'))
252                .unwrap_or(i);
253            if let Some(x) = allow.remove(name) {
254                self = self.add_sort(SimpleExpr::Column(x), order);
255            }
256        }
257        self
258    }
259
260    /// Shortcut of [`sea_orm::Condition::any`].
261    pub fn any() -> sea_orm::Condition {
262        sea_orm::Condition::any()
263    }
264
265    /// Shortcut of [`sea_orm::Condition::all`].
266    pub fn all() -> sea_orm::Condition {
267        sea_orm::Condition::all()
268    }
269
270    /// AND one more condition.
271    #[allow(clippy::should_implement_trait)]
272    pub fn add<C>(mut self, condition: C) -> Self
273    where
274        C: Into<sea_orm::Condition>,
275    {
276        self.cond = self.cond.add(condition.into());
277        self
278    }
279
280    /// AND one more condition, ignored when `None`.
281    pub fn add_option<C>(mut self, condition: Option<C>) -> Self
282    where
283        C: Into<sea_orm::Condition>,
284    {
285        self.cond = self.cond.add_option(condition);
286        self
287    }
288
289    /// Apply the sort order and filters to a `Select`.
290    ///
291    /// Returns the built query together with the detached pagination, ready
292    /// for [`SelectPage::select_page`].
293    pub fn build<E>(self, mut q: Select<E>) -> (Select<E>, Option<PaginationParam>)
294    where
295        E: EntityTrait,
296    {
297        for i in self.order {
298            q = q.order_by(i.0, i.1);
299        }
300        (q.filter(self.cond), self.page)
301    }
302
303    /// [`Condition::build`] + [`SelectPage::select_page`] in one call;
304    /// returns `(items, total_count)`.
305    pub async fn select_page<M, E, C>(self, q: Select<E>, db: &C) -> Result<(Vec<E::Model>, u64)>
306    where
307        E: EntityTrait<Model = M>,
308        M: FromQueryResult + Sized + Send + Sync,
309        C: ConnectionTrait,
310    {
311        let (q, page) = self.build(q);
312        q.select_page(db, page).await
313    }
314
315    /// Add created/updated time range filters from [`TimeParam`], using the
316    /// columns provided by [`DefaultColumnTrait`] (usually implemented by the
317    /// `entity_timestamp` macro).
318    #[cfg(feature = "entity")]
319    pub fn add_time<T>(mut self, p: &TimeParam) -> Self
320    where
321        T: DefaultColumnTrait,
322    {
323        self = self.add_option(p.created_start.map(|x| T::get_created_at().gte(x)));
324        self = self.add_option(p.created_end.map(|x| T::get_created_at().lte(x)));
325        self = self.add_option(p.updated_start.map(|x| T::get_updated_at().gte(x)));
326        self = self.add_option(p.updated_end.map(|x| T::get_updated_at().lte(x)));
327        self
328    }
329}
330
331/// `validator` helper checking that a list contains no duplicate items.
332///
333/// # Errors
334/// Will return `Err` when `x` has duplicate items.
335pub fn unique_validator<T: Eq + Hash>(x: &Vec<T>) -> Result<(), ValidationError> {
336    if utils::is_unique(x) {
337        Ok(())
338    } else {
339        Err(ValidationError::new("not unique"))
340    }
341}
342
343/// Request payload carrying a list of unique IDs.
344#[derive(Debug, Validate, Deserialize)]
345pub struct IDsReq {
346    /// Entity IDs, must not contain duplicates.
347    #[validate(custom(function = "unique_validator"))]
348    pub id: Vec<HyUuid>,
349}
350
351/// Request payload carrying an optional list of unique IDs.
352#[derive(Debug, Validate, Deserialize)]
353pub struct OptionIDsReq {
354    /// Entity IDs, must not contain duplicates.
355    #[validate(custom(function = "unique_validator"))]
356    pub id: Option<Vec<HyUuid>>,
357}
358
359/// Created/updated time range filters, consumed by
360/// [`Condition::add_time`].
361///
362/// All values are naive UTC datetimes, the same type the `entity_timestamp`
363/// macro stores, and deserialize from *strings* like `2024-01-01T00:00:00`
364/// (`T` separator required, fractional seconds optional).
365#[cfg(feature = "seaorm")]
366#[derive(Debug, Deserialize, Validate)]
367pub struct TimeParam {
368    /// `created_at >= created_start` filter.
369    pub created_start: Option<DateTime>,
370    /// `created_at <= created_end` filter.
371    pub created_end: Option<DateTime>,
372
373    /// `updated_at >= updated_start` filter.
374    pub updated_start: Option<DateTime>,
375    /// `updated_at <= updated_end` filter.
376    pub updated_end: Option<DateTime>,
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn page_data_new() {
385        let page = PageData::new((vec![1, 2, 3], 7));
386        assert_eq!(page.total, 7);
387        assert_eq!(page.data, vec![1, 2, 3]);
388    }
389
390    #[test]
391    fn page_data_serialize() {
392        let value = serde_json::to_value(PageData::new((vec!["a"], 1))).unwrap();
393        assert_eq!(value, serde_json::json!({"total": 1, "data": ["a"]}));
394    }
395
396    #[test]
397    fn pagination_default() {
398        let p = PaginationParam::default();
399        assert_eq!((p.page, p.size), (1, 10));
400        assert_eq!((p.left(), p.right()), (0, 10));
401
402        let p = PaginationParam::new();
403        assert_eq!((p.page, p.size), (1, 10));
404    }
405
406    #[test]
407    fn pagination_deserialize() {
408        let p: PaginationParam = serde_json::from_str(r#"{"page": 2, "size": 20}"#).unwrap();
409        assert_eq!((p.page, p.size), (2, 20));
410        // Missing fields fall back to defaults.
411        let p: PaginationParam = serde_json::from_str("{}").unwrap();
412        assert_eq!((p.page, p.size), (1, 10));
413        // String numbers are rejected.
414        assert!(serde_json::from_str::<PaginationParam>(r#"{"page": "2"}"#).is_err());
415    }
416
417    #[test]
418    fn pagination_validate() {
419        // Deserialization alone does not reject out-of-range values.
420        let p: PaginationParam = serde_json::from_str(r#"{"page": 0}"#).unwrap();
421        assert!(p.validate().is_err());
422        let p: PaginationParam = serde_json::from_str(r#"{"size": 101}"#).unwrap();
423        assert!(p.validate().is_err());
424        let p: PaginationParam = serde_json::from_str(r#"{"page": 5, "size": 100}"#).unwrap();
425        assert!(p.validate().is_ok());
426    }
427
428    #[test]
429    fn pagination_split() {
430        let p = PaginationParam { page: 2, size: 3 };
431        let page = p.split(vec![1, 2, 3, 4, 5, 6, 7]);
432        assert_eq!(page.total, 7);
433        assert_eq!(page.data, vec![4, 5, 6]);
434    }
435
436    #[test]
437    fn pagination_bounds_saturate() {
438        // `page: 0` (only reachable without validation) must not underflow.
439        let p = PaginationParam { page: 0, size: 10 };
440        assert_eq!((p.left(), p.right()), (0, 0));
441
442        // Huge values saturate instead of overflowing.
443        let p = PaginationParam {
444            page: u64::MAX,
445            size: 100,
446        };
447        assert_eq!(p.left(), u64::MAX);
448        assert_eq!(p.right(), u64::MAX);
449        assert_eq!(PaginationParam::round(u64::MAX), usize::MAX as usize);
450
451        // An out-of-range window yields an empty page instead of a panic.
452        let page = p.split(vec![1, 2, 3]);
453        assert_eq!(page.total, 3);
454        assert!(page.data.is_empty());
455    }
456
457    #[test]
458    fn unique_validator_works() {
459        assert!(unique_validator(&Vec::<u8>::new()).is_ok());
460        assert!(unique_validator(&vec![1, 2, 3]).is_ok());
461        assert!(unique_validator(&vec![1, 2, 2]).is_err());
462    }
463
464    #[test]
465    fn ids_req_validate() {
466        let a = HyUuid::new();
467        let b = HyUuid::new();
468
469        let req: IDsReq =
470            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), b.to_string()] }))
471                .unwrap();
472        assert!(req.validate().is_ok());
473
474        // Duplicates pass deserialization but fail validation.
475        let req: IDsReq =
476            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), a.to_string()] }))
477                .unwrap();
478        assert!(req.validate().is_err());
479    }
480
481    #[test]
482    fn option_ids_req_validate() {
483        let a = HyUuid::new();
484
485        // Missing or null ids skip validation.
486        let req: OptionIDsReq = serde_json::from_value(serde_json::json!({})).unwrap();
487        assert!(req.id.is_none());
488        assert!(req.validate().is_ok());
489        let req: OptionIDsReq = serde_json::from_value(serde_json::json!({ "id": null })).unwrap();
490        assert!(req.validate().is_ok());
491
492        let req: OptionIDsReq =
493            serde_json::from_value(serde_json::json!({ "id": [a.to_string()] })).unwrap();
494        assert!(req.validate().is_ok());
495        let req: OptionIDsReq =
496            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), a.to_string()] }))
497                .unwrap();
498        assert!(req.validate().is_err());
499    }
500
501    #[cfg(feature = "seaorm")]
502    #[test]
503    fn time_param_deserialize() {
504        let p: TimeParam = serde_json::from_str(
505            r#"{"created_start": "2024-01-01T00:00:00", "created_end": "2024-01-01T23:59:59.5"}"#,
506        )
507        .unwrap();
508        assert!(p.created_start.is_some());
509        assert!(p.created_end.is_some());
510        assert!(p.updated_start.is_none());
511        assert!(p.updated_end.is_none());
512
513        // The space-separated `Display` form is rejected, only `T` is accepted.
514        assert!(
515            serde_json::from_str::<TimeParam>(r#"{"created_start": "2024-01-01 00:00:00"}"#)
516                .is_err()
517        );
518    }
519}