1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::{FindOpts, tenant::validate_identifier_path};
16
17#[derive(Debug, Clone)]
23pub struct QueryConfig {
24 pub default_page_size: i64,
26 pub max_page_size: i64,
28 pub allowed_sorts: Vec<String>,
30 pub allowed_filters: Vec<String>,
32}
33
34impl Default for QueryConfig {
35 fn default() -> Self {
36 Self {
37 default_page_size: 20,
38 max_page_size: 100,
39 allowed_sorts: Vec::new(),
40 allowed_filters: Vec::new(),
41 }
42 }
43}
44
45impl QueryConfig {
46 #[must_use]
48 fn sanitized(&self) -> Self {
49 Self {
50 default_page_size: self.default_page_size.max(1),
51 max_page_size: self.max_page_size.max(1),
52 allowed_sorts: self.allowed_sorts.clone(),
53 allowed_filters: self.allowed_filters.clone(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum SortOrder {
66 #[default]
68 Asc,
69 Desc,
71}
72
73impl SortOrder {
74 pub fn as_sql(&self) -> &'static str {
76 match self {
77 Self::Asc => "ASC",
78 Self::Desc => "DESC",
79 }
80 }
81}
82
83impl std::fmt::Display for SortOrder {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.write_str(self.as_sql())
86 }
87}
88
89#[derive(Debug, Clone)]
95pub struct QueryParams {
96 pub page: i64,
98 pub page_size: i64,
100 pub sort_by: Option<String>,
102 pub sort_order: SortOrder,
104 pub filters: HashMap<String, String>,
106}
107
108impl QueryParams {
109 pub fn limit(&self) -> i64 {
111 self.page_size
112 }
113
114 pub fn offset(&self) -> i64 {
116 (self.page - 1) * self.page_size
117 }
118
119 pub fn to_find_opts(&self) -> FindOpts {
121 let mut opts = FindOpts::default()
122 .with_limit(self.limit())
123 .with_offset(self.offset());
124
125 if let Some(ref col) = self.sort_by {
126 opts = opts.order_by(&format!("{col} {}", self.sort_order));
127 }
128
129 for (col, val) in &self.filters {
130 opts = opts.filter(col, val.clone());
131 }
132
133 opts
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Pagination {
144 pub page: i64,
146 pub page_size: i64,
148 pub total: i64,
150 pub total_pages: i64,
152}
153
154impl Pagination {
155 pub fn new(page: i64, page_size: i64, total: i64) -> Self {
157 let total_pages = if total == 0 || page_size == 0 {
158 0
159 } else {
160 (total + page_size - 1) / page_size
161 };
162 Self {
163 page,
164 page_size,
165 total,
166 total_pages,
167 }
168 }
169}
170
171#[derive(Debug, Clone, Serialize)]
177pub struct PaginatedResult<T> {
178 pub data: Vec<T>,
180 pub pagination: Pagination,
182}
183
184impl<T> PaginatedResult<T> {
185 pub fn new(data: Vec<T>, page: i64, page_size: i64, total: i64) -> Self {
187 Self {
188 data,
189 pagination: Pagination::new(page, page_size, total),
190 }
191 }
192}
193
194const RESERVED_PARAMS: &[&str] = &["page", "page_size", "pageSize", "per_page", "sort", "order"];
200
201pub fn parse_query_string(query: &str, config: &QueryConfig) -> QueryParams {
212 let config = config.sanitized();
213 let pairs = parse_pairs(query);
214
215 let page = pairs
216 .get("page")
217 .and_then(|v| v.parse::<i64>().ok())
218 .unwrap_or(1)
219 .max(1);
220
221 let raw_page_size = pairs
222 .get("page_size")
223 .or_else(|| pairs.get("pageSize"))
224 .or_else(|| pairs.get("per_page"))
225 .and_then(|v| v.parse::<i64>().ok())
226 .unwrap_or(config.default_page_size);
227 let page_size = raw_page_size.clamp(1, config.max_page_size);
228
229 let sort_by = pairs.get("sort").and_then(|v| {
230 let v = v.trim();
231 if v.is_empty() {
232 return None;
233 }
234 if is_allowed_identifier(v, &config.allowed_sorts) {
235 Some(v.to_owned())
236 } else {
237 None
238 }
239 });
240
241 let sort_order = pairs
242 .get("order")
243 .map(|v| match v.to_ascii_lowercase().as_str() {
244 "desc" => SortOrder::Desc,
245 _ => SortOrder::Asc,
246 })
247 .unwrap_or_default();
248
249 let filters: HashMap<String, String> = pairs
250 .into_iter()
251 .filter(|(k, _)| !RESERVED_PARAMS.contains(&k.as_str()))
252 .filter(|(k, _)| is_allowed_identifier(k, &config.allowed_filters))
253 .collect();
254
255 QueryParams {
256 page,
257 page_size,
258 sort_by,
259 sort_order,
260 filters,
261 }
262}
263
264fn is_allowed_identifier(value: &str, allow_list: &[String]) -> bool {
265 validate_identifier_path(value).is_ok()
266 && (allow_list.is_empty() || allow_list.iter().any(|allowed| allowed == value))
267}
268
269fn parse_pairs(query: &str) -> HashMap<String, String> {
271 query
272 .split('&')
273 .filter(|s| !s.is_empty())
274 .filter_map(|pair| {
275 let mut parts = pair.splitn(2, '=');
276 let key = parts.next()?.trim();
277 let value = parts.next().unwrap_or("").trim();
278 if key.is_empty() {
279 None
280 } else {
281 Some((key.to_owned(), value.to_owned()))
282 }
283 })
284 .collect()
285}
286
287#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn default_config() -> QueryConfig {
296 QueryConfig::default()
297 }
298
299 #[test]
302 fn parse_basic_page_and_page_size() {
303 let params = parse_query_string("page=2&page_size=10", &default_config());
304 assert_eq!(params.page, 2);
305 assert_eq!(params.page_size, 10);
306 }
307
308 #[test]
309 fn parse_page_size_alias_camel_case() {
310 let params = parse_query_string("pageSize=15", &default_config());
311 assert_eq!(params.page_size, 15);
312 }
313
314 #[test]
315 fn parse_page_size_alias_per_page() {
316 let params = parse_query_string("per_page=25", &default_config());
317 assert_eq!(params.page_size, 25);
318 }
319
320 #[test]
323 fn clamp_page_size_to_max() {
324 let config = QueryConfig {
325 max_page_size: 50,
326 ..default_config()
327 };
328 let params = parse_query_string("page_size=200", &config);
329 assert_eq!(params.page_size, 50);
330 }
331
332 #[test]
333 fn clamp_page_size_minimum_to_one() {
334 let params = parse_query_string("page_size=0", &default_config());
335 assert_eq!(params.page_size, 1);
336 }
337
338 #[test]
339 fn invalid_max_page_size_does_not_panic() {
340 let config = QueryConfig {
341 default_page_size: 0,
342 max_page_size: 0,
343 ..default_config()
344 };
345 let params = parse_query_string("page_size=10", &config);
346 assert_eq!(params.page_size, 1);
347 }
348
349 #[test]
350 fn clamp_page_min_to_one() {
351 let params = parse_query_string("page=0", &default_config());
352 assert_eq!(params.page, 1);
353 }
354
355 #[test]
356 fn negative_page_clamps_to_one() {
357 let params = parse_query_string("page=-5", &default_config());
358 assert_eq!(params.page, 1);
359 }
360
361 #[test]
364 fn defaults_when_empty_query() {
365 let params = parse_query_string("", &default_config());
366 assert_eq!(params.page, 1);
367 assert_eq!(params.page_size, 20);
368 assert!(params.sort_by.is_none());
369 assert_eq!(params.sort_order, SortOrder::Asc);
370 assert!(params.filters.is_empty());
371 }
372
373 #[test]
374 fn defaults_when_no_params_provided() {
375 let params = parse_query_string("unrelated=foo", &default_config());
376 assert_eq!(params.page, 1);
377 assert_eq!(params.page_size, 20);
378 }
379
380 #[test]
383 fn parse_sort_and_order() {
384 let params = parse_query_string("sort=name&order=desc", &default_config());
385 assert_eq!(params.sort_by.as_deref(), Some("name"));
386 assert_eq!(params.sort_order, SortOrder::Desc);
387 }
388
389 #[test]
390 fn parse_sort_defaults_to_asc() {
391 let params = parse_query_string("sort=created_at", &default_config());
392 assert_eq!(params.sort_by.as_deref(), Some("created_at"));
393 assert_eq!(params.sort_order, SortOrder::Asc);
394 }
395
396 #[test]
397 fn sort_rejected_when_not_in_allowed_sorts() {
398 let config = QueryConfig {
399 allowed_sorts: vec!["name".into(), "created_at".into()],
400 ..default_config()
401 };
402 let params = parse_query_string("sort=email", &config);
403 assert!(params.sort_by.is_none());
404 }
405
406 #[test]
407 fn unsafe_sort_identifier_rejected_without_allow_list() {
408 let params = parse_query_string("sort=name;DROP TABLE users&order=desc", &default_config());
409 assert!(params.sort_by.is_none());
410 }
411
412 #[test]
413 fn sort_accepted_when_in_allowed_sorts() {
414 let config = QueryConfig {
415 allowed_sorts: vec!["name".into()],
416 ..default_config()
417 };
418 let params = parse_query_string("sort=name", &config);
419 assert_eq!(params.sort_by.as_deref(), Some("name"));
420 }
421
422 #[test]
425 fn parse_filters() {
426 let params = parse_query_string("status=active&type=premium", &default_config());
427 assert_eq!(params.filters.get("status").unwrap(), "active");
428 assert_eq!(params.filters.get("type").unwrap(), "premium");
429 }
430
431 #[test]
432 fn allowed_filters_enforcement() {
433 let config = QueryConfig {
434 allowed_filters: vec!["status".into()],
435 ..default_config()
436 };
437 let params = parse_query_string("status=active&type=premium", &config);
438 assert_eq!(params.filters.get("status").unwrap(), "active");
439 assert!(!params.filters.contains_key("type"));
440 }
441
442 #[test]
443 fn unsafe_filter_identifier_rejected_without_allow_list() {
444 let params = parse_query_string("status;DELETE=active&safe_filter=yes", &default_config());
445 assert!(!params.filters.contains_key("status;DELETE"));
446 assert_eq!(
447 params.filters.get("safe_filter").map(String::as_str),
448 Some("yes")
449 );
450 }
451
452 #[test]
453 fn reserved_params_not_treated_as_filters() {
454 let params = parse_query_string(
455 "page=1&page_size=10&sort=name&order=asc&status=active",
456 &default_config(),
457 );
458 assert!(!params.filters.contains_key("page"));
459 assert!(!params.filters.contains_key("page_size"));
460 assert!(!params.filters.contains_key("sort"));
461 assert!(!params.filters.contains_key("order"));
462 assert_eq!(params.filters.get("status").unwrap(), "active");
463 }
464
465 #[test]
468 fn limit_and_offset() {
469 let params = parse_query_string("page=3&page_size=10", &default_config());
470 assert_eq!(params.limit(), 10);
471 assert_eq!(params.offset(), 20);
472 }
473
474 #[test]
475 fn offset_is_zero_for_first_page() {
476 let params = parse_query_string("page=1&page_size=25", &default_config());
477 assert_eq!(params.offset(), 0);
478 }
479
480 #[test]
483 fn to_find_opts_basic() {
484 let params = parse_query_string(
485 "page=2&page_size=10&sort=name&order=desc&status=active",
486 &default_config(),
487 );
488 let opts = params.to_find_opts();
489 assert_eq!(opts.limit, Some(10));
490 assert_eq!(opts.offset, Some(10));
491 assert_eq!(opts.order_by, vec!["name DESC"]);
492 assert!(opts.filters.iter().any(|(k, _)| k == "status"));
493 }
494
495 #[test]
498 fn pagination_math() {
499 let p = Pagination::new(1, 10, 95);
500 assert_eq!(p.total_pages, 10);
501 }
502
503 #[test]
504 fn pagination_exact_division() {
505 let p = Pagination::new(1, 10, 100);
506 assert_eq!(p.total_pages, 10);
507 }
508
509 #[test]
510 fn pagination_zero_total() {
511 let p = Pagination::new(1, 10, 0);
512 assert_eq!(p.total_pages, 0);
513 }
514
515 #[test]
516 fn pagination_single_item() {
517 let p = Pagination::new(1, 10, 1);
518 assert_eq!(p.total_pages, 1);
519 }
520
521 #[test]
524 fn paginated_result_construction() {
525 let result = PaginatedResult::new(vec!["a", "b", "c"], 2, 10, 25);
526 assert_eq!(result.data.len(), 3);
527 assert_eq!(result.pagination.page, 2);
528 assert_eq!(result.pagination.page_size, 10);
529 assert_eq!(result.pagination.total, 25);
530 assert_eq!(result.pagination.total_pages, 3);
531 }
532
533 #[test]
536 fn sort_order_sql_representation() {
537 assert_eq!(SortOrder::Asc.as_sql(), "ASC");
538 assert_eq!(SortOrder::Desc.as_sql(), "DESC");
539 }
540
541 #[test]
542 fn sort_order_display() {
543 assert_eq!(format!("{}", SortOrder::Asc), "ASC");
544 assert_eq!(format!("{}", SortOrder::Desc), "DESC");
545 }
546}