use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
pub const DEFAULT_PAGE_SIZE: usize = 20;
pub const MAX_PAGE_SIZE: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OffsetPagination {
pub page: usize,
pub per_page: usize,
}
impl OffsetPagination {
pub fn new(page: usize, per_page: usize) -> Self {
let page = page.max(1);
let per_page = per_page.clamp(1, MAX_PAGE_SIZE);
Self { page, per_page }
}
pub fn from_query_params(params: &HashMap<String, String>) -> Self {
let page = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
let per_page = params
.get("per_page")
.or_else(|| params.get("limit"))
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PAGE_SIZE);
Self::new(page, per_page)
}
pub fn offset(&self) -> usize {
self.page.saturating_sub(1).saturating_mul(self.per_page)
}
pub fn limit(&self) -> usize {
self.per_page
}
pub fn total_pages(&self, total_items: usize) -> usize {
total_items.div_ceil(self.per_page)
}
pub fn has_next(&self, total_items: usize) -> bool {
self.page < self.total_pages(total_items)
}
pub fn has_prev(&self) -> bool {
self.page > 1
}
}
impl Default for OffsetPagination {
fn default() -> Self {
Self::new(1, DEFAULT_PAGE_SIZE)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorPagination {
pub cursor: Option<String>,
pub limit: usize,
}
impl CursorPagination {
pub fn new(cursor: Option<String>, limit: usize) -> Self {
let limit = limit.clamp(1, MAX_PAGE_SIZE);
Self { cursor, limit }
}
pub fn from_query_params(params: &HashMap<String, String>) -> Self {
let cursor = params.get("cursor").cloned();
let limit = params
.get("limit")
.and_then(|l| l.parse().ok())
.unwrap_or(DEFAULT_PAGE_SIZE);
Self::new(cursor, limit)
}
}
impl Default for CursorPagination {
fn default() -> Self {
Self::new(None, DEFAULT_PAGE_SIZE)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginationMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
pub per_page: usize,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_pages: Option<usize>,
pub has_next: bool,
pub has_prev: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub meta: PaginationMeta,
}
impl<T> PaginatedResponse<T> {
pub fn new(data: Vec<T>, pagination: OffsetPagination, total: usize) -> Self {
Self {
data,
meta: PaginationMeta {
page: Some(pagination.page),
per_page: pagination.per_page,
total,
total_pages: Some(pagination.total_pages(total)),
has_next: pagination.has_next(total),
has_prev: pagination.has_prev(),
next_cursor: None,
},
}
}
pub fn with_cursor(
data: Vec<T>,
limit: usize,
total: usize,
next_cursor: Option<String>,
) -> Self {
Self {
data,
meta: PaginationMeta {
page: None,
per_page: limit,
total,
total_pages: None,
has_next: next_cursor.is_some(),
has_prev: false, next_cursor,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortDirection {
Asc,
Desc,
}
impl SortDirection {
pub fn to_sql(&self) -> &'static str {
match self {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
}
}
}
impl FromStr for SortDirection {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"desc" | "descending" | "-" => SortDirection::Desc,
_ => SortDirection::Asc,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortField {
pub field: String,
pub direction: SortDirection,
}
impl SortField {
pub fn new(field: impl Into<String>, direction: SortDirection) -> Self {
Self {
field: field.into(),
direction,
}
}
pub fn asc(field: impl Into<String>) -> Self {
Self::new(field, SortDirection::Asc)
}
pub fn desc(field: impl Into<String>) -> Self {
Self::new(field, SortDirection::Desc)
}
pub fn to_sql(&self) -> String {
format!("{} {}", self.field, self.direction.to_sql())
}
pub fn is_valid_field_name(field: &str) -> bool {
!field.is_empty()
&& field
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
}
impl FromStr for SortField {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(if let Some(field) = s.strip_prefix('-') {
Self::desc(field)
} else if let Some(field) = s.strip_prefix('+') {
Self::asc(field)
} else {
Self::asc(s)
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortParams {
pub fields: Vec<SortField>,
}
impl SortParams {
pub fn new(fields: Vec<SortField>) -> Self {
Self { fields }
}
pub fn from_query(params: &HashMap<String, String>) -> Self {
let sort_str = params
.get("sort")
.or_else(|| params.get("order_by"))
.map(|s| s.as_str())
.unwrap_or("");
if sort_str.is_empty() {
return Self::new(vec![]);
}
let fields = sort_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.parse::<SortField>().unwrap())
.filter(|f| SortField::is_valid_field_name(&f.field))
.collect();
Self::new(fields)
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn to_sql(&self) -> Option<String> {
if self.is_empty() {
return None;
}
Some(
self.fields
.iter()
.map(|f| f.to_sql())
.collect::<Vec<_>>()
.join(", "),
)
}
}
impl Default for SortParams {
fn default() -> Self {
Self::new(vec![])
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilterOperator {
Eq,
Ne,
Gt,
Gte,
Lt,
Lte,
In,
NotIn,
Contains,
StartsWith,
EndsWith,
IsNull,
IsNotNull,
}
impl FilterOperator {
pub fn from_suffix(suffix: &str) -> Option<Self> {
match suffix {
"eq" => Some(FilterOperator::Eq),
"ne" | "neq" => Some(FilterOperator::Ne),
"gt" => Some(FilterOperator::Gt),
"gte" | "ge" => Some(FilterOperator::Gte),
"lt" => Some(FilterOperator::Lt),
"lte" | "le" => Some(FilterOperator::Lte),
"in" => Some(FilterOperator::In),
"not_in" | "nin" => Some(FilterOperator::NotIn),
"contains" | "like" => Some(FilterOperator::Contains),
"starts_with" | "startswith" => Some(FilterOperator::StartsWith),
"ends_with" | "endswith" => Some(FilterOperator::EndsWith),
"is_null" | "isnull" => Some(FilterOperator::IsNull),
"is_not_null" | "isnotnull" | "not_null" => Some(FilterOperator::IsNotNull),
_ => None,
}
}
pub fn to_sql(&self) -> &'static str {
match self {
FilterOperator::Eq => "=",
FilterOperator::Ne => "!=",
FilterOperator::Gt => ">",
FilterOperator::Gte => ">=",
FilterOperator::Lt => "<",
FilterOperator::Lte => "<=",
FilterOperator::In => "IN",
FilterOperator::NotIn => "NOT IN",
FilterOperator::Contains => "LIKE",
FilterOperator::StartsWith => "LIKE",
FilterOperator::EndsWith => "LIKE",
FilterOperator::IsNull => "IS NULL",
FilterOperator::IsNotNull => "IS NOT NULL",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterCondition {
pub field: String,
pub operator: FilterOperator,
pub value: Option<String>,
}
impl FilterCondition {
pub fn new(field: impl Into<String>, operator: FilterOperator, value: Option<String>) -> Self {
Self {
field: field.into(),
operator,
value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterParams {
pub conditions: Vec<FilterCondition>,
}
impl FilterParams {
pub fn new(conditions: Vec<FilterCondition>) -> Self {
Self { conditions }
}
pub fn from_query(params: &HashMap<String, String>) -> Self {
let mut conditions = Vec::new();
let skip_params = [
"page", "per_page", "limit", "cursor", "sort", "order_by", "fields", "q", "search",
];
for (key, value) in params {
if skip_params.contains(&key.as_str()) {
continue;
}
if let Some((field, op_str)) = key.split_once("__")
&& let Some(operator) = FilterOperator::from_suffix(op_str)
{
conditions.push(FilterCondition::new(field, operator, Some(value.clone())));
continue;
}
conditions.push(FilterCondition::new(
key,
FilterOperator::Eq,
Some(value.clone()),
));
}
Self::new(conditions)
}
pub fn is_empty(&self) -> bool {
self.conditions.is_empty()
}
pub fn get(&self, field: &str) -> Option<&FilterCondition> {
self.conditions.iter().find(|c| c.field == field)
}
}
impl Default for FilterParams {
fn default() -> Self {
Self::new(vec![])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchParams {
pub query: Option<String>,
pub fields: Vec<String>,
}
impl SearchParams {
pub fn new(query: Option<String>, fields: Vec<String>) -> Self {
Self { query, fields }
}
pub fn from_query(params: &HashMap<String, String>) -> Self {
let query = params.get("q").or_else(|| params.get("search")).cloned();
let fields = params
.get("search_fields")
.map(|s| s.split(',').map(|f| f.trim().to_string()).collect())
.unwrap_or_default();
Self::new(query, fields)
}
pub fn is_active(&self) -> bool {
self.query.as_ref().map(|q| !q.is_empty()).unwrap_or(false)
}
}
impl Default for SearchParams {
fn default() -> Self {
Self::new(None, vec![])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSelection {
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
}
impl FieldSelection {
pub fn new(include: Option<Vec<String>>, exclude: Option<Vec<String>>) -> Self {
Self { include, exclude }
}
pub fn include(fields: Vec<String>) -> Self {
Self::new(Some(fields), None)
}
pub fn exclude(fields: Vec<String>) -> Self {
Self::new(None, Some(fields))
}
pub fn from_query(params: &HashMap<String, String>) -> Self {
let include = params.get("fields").map(|s| {
s.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect()
});
let exclude = params.get("exclude").map(|s| {
s.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect()
});
Self::new(include, exclude)
}
pub fn should_include(&self, field: &str) -> bool {
if let Some(ref include) = self.include
&& !include.contains(&field.to_string())
{
return false;
}
if let Some(ref exclude) = self.exclude
&& exclude.contains(&field.to_string())
{
return false;
}
true
}
pub fn is_active(&self) -> bool {
self.include.is_some() || self.exclude.is_some()
}
}
impl Default for FieldSelection {
fn default() -> Self {
Self::new(None, None)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryParams {
pub pagination: OffsetPagination,
pub sort: SortParams,
pub filter: FilterParams,
pub search: SearchParams,
pub fields: FieldSelection,
}
impl QueryParams {
pub fn from_hashmap(params: &HashMap<String, String>) -> Self {
Self {
pagination: OffsetPagination::from_query_params(params),
sort: SortParams::from_query(params),
filter: FilterParams::from_query(params),
search: SearchParams::from_query(params),
fields: FieldSelection::from_query(params),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offset_pagination() {
let p = OffsetPagination::new(1, 20);
assert_eq!(p.offset(), 0);
assert_eq!(p.limit(), 20);
let p = OffsetPagination::new(2, 20);
assert_eq!(p.offset(), 20);
let p = OffsetPagination::new(3, 50);
assert_eq!(p.offset(), 100);
}
#[test]
fn test_offset_pagination_no_overflow() {
let p = OffsetPagination::new(usize::MAX, MAX_PAGE_SIZE);
assert_eq!(p.offset(), usize::MAX);
let mut params = HashMap::new();
params.insert("page".to_string(), usize::MAX.to_string());
params.insert("per_page".to_string(), "100".to_string());
let p = OffsetPagination::from_query_params(¶ms);
let _ = p.offset();
}
#[test]
fn test_offset_pagination_total_pages() {
let p = OffsetPagination::new(1, 20);
assert_eq!(p.total_pages(100), 5);
assert_eq!(p.total_pages(95), 5);
assert_eq!(p.total_pages(101), 6);
}
#[test]
fn test_sort_field_from_str() {
let f: SortField = "name".parse().unwrap();
assert_eq!(f.field, "name");
assert_eq!(f.direction, SortDirection::Asc);
let f: SortField = "-created_at".parse().unwrap();
assert_eq!(f.field, "created_at");
assert_eq!(f.direction, SortDirection::Desc);
let f: SortField = "+email".parse().unwrap();
assert_eq!(f.field, "email");
assert_eq!(f.direction, SortDirection::Asc);
}
#[test]
fn test_sort_params_from_query() {
let mut params = HashMap::new();
params.insert("sort".to_string(), "-created_at,name,+email".to_string());
let sort = SortParams::from_query(¶ms);
assert_eq!(sort.fields.len(), 3);
assert_eq!(sort.fields[0].field, "created_at");
assert_eq!(sort.fields[0].direction, SortDirection::Desc);
assert_eq!(sort.fields[1].field, "name");
assert_eq!(sort.fields[1].direction, SortDirection::Asc);
}
#[test]
fn test_sort_params_rejects_sql_injection() {
let mut params = HashMap::new();
params.insert(
"sort".to_string(),
"name; DROP TABLE users,-created_at,(SELECT 1),email".to_string(),
);
let sort = SortParams::from_query(¶ms);
assert_eq!(sort.fields.len(), 2);
assert_eq!(sort.fields[0].field, "created_at");
assert_eq!(sort.fields[1].field, "email");
let sql = sort.to_sql().unwrap();
assert_eq!(sql, "created_at DESC, email ASC");
}
#[test]
fn test_sort_field_name_validation() {
assert!(SortField::is_valid_field_name("created_at"));
assert!(SortField::is_valid_field_name("Field123"));
assert!(!SortField::is_valid_field_name(""));
assert!(!SortField::is_valid_field_name("name; DROP TABLE users"));
assert!(!SortField::is_valid_field_name("a.b"));
assert!(!SortField::is_valid_field_name("a b"));
}
#[test]
fn test_filter_params_from_query() {
let mut params = HashMap::new();
params.insert("status".to_string(), "active".to_string());
params.insert("age__gte".to_string(), "18".to_string());
let filters = FilterParams::from_query(¶ms);
assert_eq!(filters.conditions.len(), 2);
let status_filter = filters.get("status").unwrap();
assert_eq!(status_filter.operator, FilterOperator::Eq);
let age_filter = filters.get("age").unwrap();
assert_eq!(age_filter.operator, FilterOperator::Gte);
}
#[test]
fn test_field_selection() {
let mut params = HashMap::new();
params.insert("fields".to_string(), "id,name,email".to_string());
let fields = FieldSelection::from_query(¶ms);
assert!(fields.should_include("name"));
assert!(!fields.should_include("password"));
}
#[test]
fn test_field_selection_exclude() {
let mut params = HashMap::new();
params.insert("exclude".to_string(), "password,secret".to_string());
let fields = FieldSelection::from_query(¶ms);
assert!(fields.should_include("name"));
assert!(!fields.should_include("password"));
}
}