Skip to main content

karbon_framework/db/
pagination.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3/// Pagination query parameters (from URL: ?page=1&per_page=20&sort=id&order=desc)
4#[derive(Debug, Clone, Deserialize)]
5pub struct PaginationParams {
6    #[serde(default = "default_page", deserialize_with = "string_or_u32")]
7    pub page: u32,
8    #[serde(default = "default_per_page", deserialize_with = "string_or_u32", alias = "limit")]
9    pub per_page: u32,
10    #[serde(default)]
11    pub sort: Option<String>,
12    #[serde(default = "default_order")]
13    pub order: String,
14    #[serde(default)]
15    pub search: Option<String>,
16}
17
18/// Deserialize a u32 from either a number or a string (query strings are always strings)
19fn string_or_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
20where
21    D: Deserializer<'de>,
22{
23    #[derive(Deserialize)]
24    #[serde(untagged)]
25    enum StringOrU32 {
26        Num(u32),
27        Str(String),
28    }
29
30    match StringOrU32::deserialize(deserializer)? {
31        StringOrU32::Num(n) => Ok(n),
32        StringOrU32::Str(s) => s.parse::<u32>().map_err(serde::de::Error::custom),
33    }
34}
35
36fn default_page() -> u32 {
37    1
38}
39fn default_per_page() -> u32 {
40    20
41}
42fn default_order() -> String {
43    "desc".to_string()
44}
45
46/// Maximum allowed per_page to prevent DoS via huge result sets
47const MAX_PER_PAGE: u32 = 200;
48
49impl PaginationParams {
50    /// Returns per_page clamped to MAX_PER_PAGE (200)
51    pub fn safe_per_page(&self) -> u32 {
52        self.per_page.min(MAX_PER_PAGE).max(1)
53    }
54
55    /// Calculate SQL OFFSET (uses safe_per_page)
56    pub fn offset(&self) -> u32 {
57        (self.page.saturating_sub(1)) * self.safe_per_page()
58    }
59
60    /// Validated ORDER direction (only asc/desc)
61    pub fn order_direction(&self) -> &str {
62        match self.order.to_lowercase().as_str() {
63            "asc" => "ASC",
64            _ => "DESC",
65        }
66    }
67
68    /// Validate and return the sort column against an allow-list
69    pub fn sort_column<'a>(&'a self, allowed: &[&'a str], default: &'a str) -> &'a str {
70        match &self.sort {
71            Some(col) if allowed.contains(&col.as_str()) => col.as_str(),
72            _ => default,
73        }
74    }
75}
76
77/// Paginated response wrapper
78#[derive(Debug, Serialize)]
79pub struct Paginated<T: Serialize> {
80    pub data: Vec<T>,
81    pub meta: PaginationMeta,
82}
83
84#[derive(Debug, Serialize)]
85pub struct PaginationMeta {
86    pub page: u32,
87    pub per_page: u32,
88    pub total: u64,
89    pub total_pages: u32,
90    pub has_next: bool,
91    pub has_prev: bool,
92}
93
94impl<T: Serialize> Paginated<T> {
95    /// Create a paginated response from data and total count
96    pub fn new(data: Vec<T>, total: u64, params: &PaginationParams) -> Self {
97        let total_pages = ((total as f64) / (params.per_page as f64)).ceil() as u32;
98        Self {
99            data,
100            meta: PaginationMeta {
101                page: params.page,
102                per_page: params.per_page,
103                total,
104                total_pages,
105                has_next: params.page < total_pages,
106                has_prev: params.page > 1,
107            },
108        }
109    }
110}