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 {
211 let config = config.sanitized();
212 let pairs = parse_pairs(query);
213
214 let page = pairs
215 .get("page")
216 .and_then(|v| v.parse::<i64>().ok())
217 .unwrap_or(1)
218 .max(1);
219
220 let raw_page_size = pairs
221 .get("page_size")
222 .or_else(|| pairs.get("pageSize"))
223 .or_else(|| pairs.get("per_page"))
224 .and_then(|v| v.parse::<i64>().ok())
225 .unwrap_or(config.default_page_size);
226 let page_size = raw_page_size.clamp(1, config.max_page_size);
227
228 let sort_by = pairs.get("sort").and_then(|v| {
229 let v = v.trim();
230 if v.is_empty() {
231 return None;
232 }
233 if is_allowed_identifier(v, &config.allowed_sorts) {
234 Some(v.to_owned())
235 } else {
236 None
237 }
238 });
239
240 let sort_order = pairs
241 .get("order")
242 .map(|v| match v.to_ascii_lowercase().as_str() {
243 "desc" => SortOrder::Desc,
244 _ => SortOrder::Asc,
245 })
246 .unwrap_or_default();
247
248 let filters: HashMap<String, String> = pairs
249 .into_iter()
250 .filter(|(k, _)| !RESERVED_PARAMS.contains(&k.as_str()))
251 .filter(|(k, _)| is_allowed_identifier(k, &config.allowed_filters))
252 .collect();
253
254 QueryParams {
255 page,
256 page_size,
257 sort_by,
258 sort_order,
259 filters,
260 }
261}
262
263fn is_allowed_identifier(value: &str, allow_list: &[String]) -> bool {
264 validate_identifier_path(value).is_ok()
265 && (allow_list.is_empty() || allow_list.iter().any(|allowed| allowed == value))
266}
267
268fn parse_pairs(query: &str) -> HashMap<String, String> {
270 query
271 .split('&')
272 .filter(|s| !s.is_empty())
273 .filter_map(|pair| {
274 let mut parts = pair.splitn(2, '=');
275 let key = parts.next()?.trim();
276 let value = parts.next().unwrap_or("").trim();
277 if key.is_empty() {
278 None
279 } else {
280 Some((key.to_owned(), value.to_owned()))
281 }
282 })
283 .collect()
284}
285
286#[cfg(test)]
287mod coverage_tests {
288 use super::*;
289
290 #[test]
291 fn query_params_ignore_blank_sort_and_blank_pair_keys() {
292 let params = parse_query_string(
293 "sort= &order=desc&&=ignored&status=open",
294 &QueryConfig::default(),
295 );
296
297 assert_eq!(params.sort_by, None);
298 assert_eq!(params.sort_order, SortOrder::Desc);
299 assert_eq!(
300 params.filters.get("status").map(String::as_str),
301 Some("open")
302 );
303 assert!(!params.filters.contains_key(""));
304 }
305}
306
307#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn default_config() -> QueryConfig {
316 QueryConfig::default()
317 }
318
319 #[test]
322 fn parse_basic_page_and_page_size() {
323 let params = parse_query_string("page=2&page_size=10", &default_config());
324 assert_eq!(params.page, 2);
325 assert_eq!(params.page_size, 10);
326 }
327
328 #[test]
329 fn parse_page_size_alias_camel_case() {
330 let params = parse_query_string("pageSize=15", &default_config());
331 assert_eq!(params.page_size, 15);
332 }
333
334 #[test]
335 fn parse_page_size_alias_per_page() {
336 let params = parse_query_string("per_page=25", &default_config());
337 assert_eq!(params.page_size, 25);
338 }
339
340 #[test]
343 fn clamp_page_size_to_max() {
344 let config = QueryConfig {
345 max_page_size: 50,
346 ..default_config()
347 };
348 let params = parse_query_string("page_size=200", &config);
349 assert_eq!(params.page_size, 50);
350 }
351
352 #[test]
353 fn clamp_page_size_minimum_to_one() {
354 let params = parse_query_string("page_size=0", &default_config());
355 assert_eq!(params.page_size, 1);
356 }
357
358 #[test]
359 fn invalid_max_page_size_does_not_panic() {
360 let config = QueryConfig {
361 default_page_size: 0,
362 max_page_size: 0,
363 ..default_config()
364 };
365 let params = parse_query_string("page_size=10", &config);
366 assert_eq!(params.page_size, 1);
367 }
368
369 #[test]
370 fn clamp_page_min_to_one() {
371 let params = parse_query_string("page=0", &default_config());
372 assert_eq!(params.page, 1);
373 }
374
375 #[test]
376 fn negative_page_clamps_to_one() {
377 let params = parse_query_string("page=-5", &default_config());
378 assert_eq!(params.page, 1);
379 }
380
381 #[test]
384 fn defaults_when_empty_query() {
385 let params = parse_query_string("", &default_config());
386 assert_eq!(params.page, 1);
387 assert_eq!(params.page_size, 20);
388 assert!(params.sort_by.is_none());
389 assert_eq!(params.sort_order, SortOrder::Asc);
390 assert!(params.filters.is_empty());
391 }
392
393 #[test]
394 fn defaults_when_no_params_provided() {
395 let params = parse_query_string("unrelated=foo", &default_config());
396 assert_eq!(params.page, 1);
397 assert_eq!(params.page_size, 20);
398 }
399
400 #[test]
403 fn parse_sort_and_order() {
404 let params = parse_query_string("sort=name&order=desc", &default_config());
405 assert_eq!(params.sort_by.as_deref(), Some("name"));
406 assert_eq!(params.sort_order, SortOrder::Desc);
407 }
408
409 #[test]
410 fn parse_sort_defaults_to_asc() {
411 let params = parse_query_string("sort=created_at", &default_config());
412 assert_eq!(params.sort_by.as_deref(), Some("created_at"));
413 assert_eq!(params.sort_order, SortOrder::Asc);
414 }
415
416 #[test]
417 fn sort_rejected_when_not_in_allowed_sorts() {
418 let config = QueryConfig {
419 allowed_sorts: vec!["name".into(), "created_at".into()],
420 ..default_config()
421 };
422 let params = parse_query_string("sort=email", &config);
423 assert!(params.sort_by.is_none());
424 }
425
426 #[test]
427 fn unsafe_sort_identifier_rejected_without_allow_list() {
428 let params = parse_query_string("sort=name;DROP TABLE users&order=desc", &default_config());
429 assert!(params.sort_by.is_none());
430 }
431
432 #[test]
433 fn sort_accepted_when_in_allowed_sorts() {
434 let config = QueryConfig {
435 allowed_sorts: vec!["name".into()],
436 ..default_config()
437 };
438 let params = parse_query_string("sort=name", &config);
439 assert_eq!(params.sort_by.as_deref(), Some("name"));
440 }
441
442 #[test]
445 fn parse_filters() {
446 let params = parse_query_string("status=active&type=premium", &default_config());
447 assert_eq!(params.filters.get("status").unwrap(), "active");
448 assert_eq!(params.filters.get("type").unwrap(), "premium");
449 }
450
451 #[test]
452 fn allowed_filters_enforcement() {
453 let config = QueryConfig {
454 allowed_filters: vec!["status".into()],
455 ..default_config()
456 };
457 let params = parse_query_string("status=active&type=premium", &config);
458 assert_eq!(params.filters.get("status").unwrap(), "active");
459 assert!(!params.filters.contains_key("type"));
460 }
461
462 #[test]
463 fn unsafe_filter_identifier_rejected_without_allow_list() {
464 let params = parse_query_string("status;DELETE=active&safe_filter=yes", &default_config());
465 assert!(!params.filters.contains_key("status;DELETE"));
466 assert_eq!(
467 params.filters.get("safe_filter").map(String::as_str),
468 Some("yes")
469 );
470 }
471
472 #[test]
473 fn reserved_params_not_treated_as_filters() {
474 let params = parse_query_string(
475 "page=1&page_size=10&sort=name&order=asc&status=active",
476 &default_config(),
477 );
478 assert!(!params.filters.contains_key("page"));
479 assert!(!params.filters.contains_key("page_size"));
480 assert!(!params.filters.contains_key("sort"));
481 assert!(!params.filters.contains_key("order"));
482 assert_eq!(params.filters.get("status").unwrap(), "active");
483 }
484
485 #[test]
488 fn limit_and_offset() {
489 let params = parse_query_string("page=3&page_size=10", &default_config());
490 assert_eq!(params.limit(), 10);
491 assert_eq!(params.offset(), 20);
492 }
493
494 #[test]
495 fn offset_is_zero_for_first_page() {
496 let params = parse_query_string("page=1&page_size=25", &default_config());
497 assert_eq!(params.offset(), 0);
498 }
499
500 #[test]
503 fn to_find_opts_basic() {
504 let params = parse_query_string(
505 "page=2&page_size=10&sort=name&order=desc&status=active",
506 &default_config(),
507 );
508 let opts = params.to_find_opts();
509 assert_eq!(opts.limit, Some(10));
510 assert_eq!(opts.offset, Some(10));
511 assert_eq!(opts.order_by, vec!["name DESC"]);
512 assert!(opts.filters.iter().any(|(k, _)| k == "status"));
513 }
514
515 #[test]
518 fn pagination_math() {
519 let p = Pagination::new(1, 10, 95);
520 assert_eq!(p.total_pages, 10);
521 }
522
523 #[test]
524 fn pagination_exact_division() {
525 let p = Pagination::new(1, 10, 100);
526 assert_eq!(p.total_pages, 10);
527 }
528
529 #[test]
530 fn pagination_zero_total() {
531 let p = Pagination::new(1, 10, 0);
532 assert_eq!(p.total_pages, 0);
533 }
534
535 #[test]
536 fn pagination_single_item() {
537 let p = Pagination::new(1, 10, 1);
538 assert_eq!(p.total_pages, 1);
539 }
540
541 #[test]
544 fn paginated_result_construction() {
545 let result = PaginatedResult::new(vec!["a", "b", "c"], 2, 10, 25);
546 assert_eq!(result.data.len(), 3);
547 assert_eq!(result.pagination.page, 2);
548 assert_eq!(result.pagination.page_size, 10);
549 assert_eq!(result.pagination.total, 25);
550 assert_eq!(result.pagination.total_pages, 3);
551 }
552
553 #[test]
556 fn sort_order_sql_representation() {
557 assert_eq!(SortOrder::Asc.as_sql(), "ASC");
558 assert_eq!(SortOrder::Desc.as_sql(), "DESC");
559 }
560
561 #[test]
562 fn sort_order_display() {
563 assert_eq!(format!("{}", SortOrder::Asc), "ASC");
564 assert_eq!(format!("{}", SortOrder::Desc), "DESC");
565 }
566}