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_repo` 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 UTC datetimes, the same type the `entity_timestamp`
363/// macro stores, and deserialize from RFC 3339 *strings* like
364/// `2024-01-01T00:00:00Z` (a timezone offset is required; non-UTC offsets
365/// are converted to UTC).
366#[cfg(feature = "seaorm")]
367#[derive(Debug, Deserialize, Validate)]
368pub struct TimeParam {
369    /// `created_at >= created_start` filter.
370    pub created_start: Option<DateTimeUtc>,
371    /// `created_at <= created_end` filter.
372    pub created_end: Option<DateTimeUtc>,
373
374    /// `updated_at >= updated_start` filter.
375    pub updated_start: Option<DateTimeUtc>,
376    /// `updated_at <= updated_end` filter.
377    pub updated_end: Option<DateTimeUtc>,
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn page_data_new() {
386        let page = PageData::new((vec![1, 2, 3], 7));
387        assert_eq!(page.total, 7);
388        assert_eq!(page.data, vec![1, 2, 3]);
389    }
390
391    #[test]
392    fn page_data_serialize() {
393        let value = serde_json::to_value(PageData::new((vec!["a"], 1))).unwrap();
394        assert_eq!(value, serde_json::json!({"total": 1, "data": ["a"]}));
395    }
396
397    #[test]
398    fn pagination_default() {
399        let p = PaginationParam::default();
400        assert_eq!((p.page, p.size), (1, 10));
401        assert_eq!((p.left(), p.right()), (0, 10));
402
403        let p = PaginationParam::new();
404        assert_eq!((p.page, p.size), (1, 10));
405    }
406
407    #[test]
408    fn pagination_deserialize() {
409        let p: PaginationParam = serde_json::from_str(r#"{"page": 2, "size": 20}"#).unwrap();
410        assert_eq!((p.page, p.size), (2, 20));
411        // Missing fields fall back to defaults.
412        let p: PaginationParam = serde_json::from_str("{}").unwrap();
413        assert_eq!((p.page, p.size), (1, 10));
414        // String numbers are rejected.
415        assert!(serde_json::from_str::<PaginationParam>(r#"{"page": "2"}"#).is_err());
416    }
417
418    #[test]
419    fn pagination_validate() {
420        // Deserialization alone does not reject out-of-range values.
421        let p: PaginationParam = serde_json::from_str(r#"{"page": 0}"#).unwrap();
422        assert!(p.validate().is_err());
423        let p: PaginationParam = serde_json::from_str(r#"{"size": 101}"#).unwrap();
424        assert!(p.validate().is_err());
425        let p: PaginationParam = serde_json::from_str(r#"{"page": 5, "size": 100}"#).unwrap();
426        assert!(p.validate().is_ok());
427    }
428
429    #[test]
430    fn pagination_split() {
431        let p = PaginationParam { page: 2, size: 3 };
432        let page = p.split(vec![1, 2, 3, 4, 5, 6, 7]);
433        assert_eq!(page.total, 7);
434        assert_eq!(page.data, vec![4, 5, 6]);
435    }
436
437    #[test]
438    fn pagination_bounds_saturate() {
439        // `page: 0` (only reachable without validation) must not underflow.
440        let p = PaginationParam { page: 0, size: 10 };
441        assert_eq!((p.left(), p.right()), (0, 0));
442
443        // Huge values saturate instead of overflowing.
444        let p = PaginationParam {
445            page: u64::MAX,
446            size: 100,
447        };
448        assert_eq!(p.left(), u64::MAX);
449        assert_eq!(p.right(), u64::MAX);
450        assert_eq!(PaginationParam::round(u64::MAX), usize::MAX as usize);
451
452        // An out-of-range window yields an empty page instead of a panic.
453        let page = p.split(vec![1, 2, 3]);
454        assert_eq!(page.total, 3);
455        assert!(page.data.is_empty());
456    }
457
458    #[test]
459    fn unique_validator_works() {
460        assert!(unique_validator(&Vec::<u8>::new()).is_ok());
461        assert!(unique_validator(&vec![1, 2, 3]).is_ok());
462        assert!(unique_validator(&vec![1, 2, 2]).is_err());
463    }
464
465    #[test]
466    fn ids_req_validate() {
467        let a = HyUuid::new();
468        let b = HyUuid::new();
469
470        let req: IDsReq =
471            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), b.to_string()] }))
472                .unwrap();
473        assert!(req.validate().is_ok());
474
475        // Duplicates pass deserialization but fail validation.
476        let req: IDsReq =
477            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), a.to_string()] }))
478                .unwrap();
479        assert!(req.validate().is_err());
480    }
481
482    #[test]
483    fn option_ids_req_validate() {
484        let a = HyUuid::new();
485
486        // Missing or null ids skip validation.
487        let req: OptionIDsReq = serde_json::from_value(serde_json::json!({})).unwrap();
488        assert!(req.id.is_none());
489        assert!(req.validate().is_ok());
490        let req: OptionIDsReq = serde_json::from_value(serde_json::json!({ "id": null })).unwrap();
491        assert!(req.validate().is_ok());
492
493        let req: OptionIDsReq =
494            serde_json::from_value(serde_json::json!({ "id": [a.to_string()] })).unwrap();
495        assert!(req.validate().is_ok());
496        let req: OptionIDsReq =
497            serde_json::from_value(serde_json::json!({ "id": [a.to_string(), a.to_string()] }))
498                .unwrap();
499        assert!(req.validate().is_err());
500    }
501
502    #[cfg(feature = "seaorm")]
503    #[test]
504    fn time_param_deserialize() {
505        let p: TimeParam = serde_json::from_str(
506            r#"{"created_start": "2024-01-01T00:00:00Z", "created_end": "2024-01-01T23:59:59.5+08:00"}"#,
507        )
508        .unwrap();
509        assert!(p.created_start.is_some());
510        assert!(p.created_end.is_some());
511        assert!(p.updated_start.is_none());
512        assert!(p.updated_end.is_none());
513        // Non-UTC offsets are converted to UTC: 23:59:59.5+08:00 is 15:59:59.5Z.
514        let expect: DateTimeUtc = "2024-01-01T15:59:59.5Z".parse().unwrap();
515        assert_eq!(p.created_end.unwrap(), expect);
516
517        // A timezone offset is required: naive strings are rejected, even
518        // with the `T` separator (a space separator is accepted by RFC 3339
519        // when the offset is present).
520        assert!(
521            serde_json::from_str::<TimeParam>(r#"{"created_start": "2024-01-01T00:00:00"}"#)
522                .is_err()
523        );
524    }
525}