1#![cfg_attr(
9 not(test),
10 deny(
11 clippy::unwrap_used,
12 clippy::unreachable,
13 clippy::expect_used,
14 clippy::panic,
15 clippy::unimplemented,
16 clippy::todo
17 )
18)]
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use std::future::Future;
23#[cfg(all(debug_assertions, feature = "openapi"))]
24use utoipa::{IntoParams, ToSchema};
25
26pub const DEFAULT_FOLDER_LIMIT: u64 = 200;
28pub const DEFAULT_FILE_LIMIT: u64 = 100;
30pub const DEFAULT_PAGE_LIMIT: u64 = 100;
32pub const MAX_PAGE_SIZE: u64 = 1000;
34
35pub type Result<T> = std::result::Result<T, ApiError>;
37
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40#[error("{message}")]
41pub struct ApiError {
42 message: String,
43}
44
45impl ApiError {
46 pub fn new(message: impl Into<String>) -> Self {
48 Self {
49 message: message.into(),
50 }
51 }
52
53 #[must_use]
55 pub fn message(&self) -> &str {
56 &self.message
57 }
58}
59
60#[derive(Debug, Clone, Copy, Default, Deserialize)]
62#[cfg_attr(
63 all(debug_assertions, feature = "openapi"),
64 derive(IntoParams, ToSchema)
65)]
66pub struct LimitOffsetQuery {
67 pub limit: Option<u64>,
69 pub offset: Option<u64>,
71}
72
73impl LimitOffsetQuery {
74 #[must_use]
76 pub fn limit_or(&self, default: u64, max: u64) -> u64 {
77 self.limit.map_or(default, |v| v.clamp(1, max))
78 }
79
80 #[must_use]
82 pub fn offset(&self) -> u64 {
83 self.offset.unwrap_or(0)
84 }
85}
86
87#[derive(Debug, Clone, Copy, Default, Deserialize)]
89#[cfg_attr(
90 all(debug_assertions, feature = "openapi"),
91 derive(IntoParams, ToSchema)
92)]
93pub struct LimitQuery {
94 pub limit: Option<u64>,
96}
97
98impl LimitQuery {
99 #[must_use]
101 pub fn limit_or(&self, default: u64, max: u64) -> u64 {
102 self.limit.map_or(default, |value| value.clamp(1, max))
103 }
104
105 #[must_use]
107 pub fn limit(&self) -> u64 {
108 self.limit_or(DEFAULT_PAGE_LIMIT, MAX_PAGE_SIZE)
109 }
110}
111
112#[derive(Debug, Clone, Copy, Default, Deserialize)]
114#[cfg_attr(
115 all(debug_assertions, feature = "openapi"),
116 derive(IntoParams, ToSchema)
117)]
118pub struct CreatedAtCursorQuery {
119 pub after_created_at: Option<DateTime<Utc>>,
121 pub after_id: Option<i64>,
123}
124
125#[derive(Debug, Clone, Copy, Default, Deserialize)]
127#[cfg_attr(
128 all(debug_assertions, feature = "openapi"),
129 derive(IntoParams, ToSchema)
130)]
131pub struct UpdatedAtCursorQuery {
132 pub after_updated_at: Option<DateTime<Utc>>,
134 pub after_id: Option<i64>,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum NullablePatch<T> {
145 #[default]
147 Absent,
148 Null,
150 Value(T),
152}
153
154impl<T> NullablePatch<T> {
155 pub fn is_present(&self) -> bool {
157 !matches!(self, Self::Absent)
158 }
159}
160
161pub fn deserialize_nullable_patch_option<'de, D, T>(
170 deserializer: D,
171) -> std::result::Result<Option<NullablePatch<T>>, D::Error>
172where
173 D: serde::Deserializer<'de>,
174 T: Deserialize<'de>,
175{
176 Option::<T>::deserialize(deserializer).map(|value| Some(NullablePatch::from(value)))
177}
178
179impl<T> From<Option<T>> for NullablePatch<T> {
180 fn from(value: Option<T>) -> Self {
181 match value {
182 Some(value) => Self::Value(value),
183 None => Self::Null,
184 }
185 }
186}
187
188impl<'de, T> Deserialize<'de> for NullablePatch<T>
189where
190 T: Deserialize<'de>,
191{
192 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
193 where
194 D: serde::Deserializer<'de>,
195 {
196 Ok(match Option::<T>::deserialize(deserializer)? {
197 Some(value) => Self::Value(value),
198 None => Self::Null,
199 })
200 }
201}
202
203#[cfg(all(debug_assertions, feature = "openapi"))]
204#[doc(hidden)]
205pub trait ApiSchema: ToSchema {}
206
207#[cfg(all(debug_assertions, feature = "openapi"))]
208impl<T: ToSchema> ApiSchema for T {}
209
210#[cfg(not(all(debug_assertions, feature = "openapi")))]
211#[doc(hidden)]
212pub trait ApiSchema {}
213
214#[cfg(not(all(debug_assertions, feature = "openapi")))]
215impl<T> ApiSchema for T {}
216
217#[derive(Debug, Clone, Serialize)]
219#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
220pub struct OffsetPage<T: Serialize + ApiSchema> {
221 pub items: Vec<T>,
223 pub total: u64,
225 pub limit: u64,
227 pub offset: u64,
229}
230
231impl<T: Serialize + ApiSchema> OffsetPage<T> {
232 #[must_use]
234 pub fn new(items: Vec<T>, total: u64, limit: u64, offset: u64) -> Self {
235 Self {
236 items,
237 total,
238 limit,
239 offset,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize)]
246#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
247pub struct CursorPage<T: Serialize + ApiSchema, C: Serialize + ApiSchema> {
248 pub items: Vec<T>,
250 pub total: u64,
252 pub limit: u64,
254 pub next_cursor: Option<C>,
256}
257
258impl<T: Serialize + ApiSchema, C: Serialize + ApiSchema> CursorPage<T, C> {
259 pub fn new(items: Vec<T>, total: u64, limit: u64, next_cursor: Option<C>) -> Self {
261 Self {
262 items,
263 total,
264 limit,
265 next_cursor,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize)]
272#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
273pub struct IdCursor {
274 pub id: i64,
276}
277
278#[derive(Debug, Clone, Serialize)]
280#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
281pub struct StringIdCursor {
282 pub value: String,
284 pub id: i64,
286}
287
288#[derive(Debug, Clone, Serialize)]
290#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
291pub struct SortOrderNameIdCursor {
292 pub sort_order: i32,
294 pub name: String,
296 pub id: i64,
298}
299
300#[derive(Debug, Clone, Serialize)]
302#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
303pub struct EnabledPriorityIdCursor {
304 pub enabled: bool,
306 pub priority: i32,
308 pub id: i64,
310}
311
312#[derive(Debug, Clone, Serialize)]
314#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
315pub struct DateTimeIdCursor {
316 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
318 pub value: DateTime<Utc>,
319 pub id: i64,
321}
322
323#[derive(Debug, Clone, Serialize)]
325#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
326pub struct DateTimeStringCursor {
327 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
329 pub value: DateTime<Utc>,
330 pub id: String,
332}
333
334pub async fn load_offset_page<T, F, Fut>(
340 limit: u64,
341 offset: u64,
342 max_limit: u64,
343 fetch: F,
344) -> Result<OffsetPage<T>>
345where
346 T: Serialize + ApiSchema,
347 F: FnOnce(u64, u64) -> Fut,
348 Fut: Future<Output = Result<(Vec<T>, u64)>>,
349{
350 let limit = limit.clamp(1, max_limit);
351 let (items, total) = fetch(limit, offset).await?;
352 Ok(OffsetPage::new(items, total, limit, offset))
353}
354
355pub fn parse_datetime_id_cursor(
361 value: Option<DateTime<Utc>>,
362 id: Option<i64>,
363 value_name: &str,
364) -> Result<Option<(DateTime<Utc>, i64)>> {
365 match (value, id) {
366 (None, None) => Ok(None),
367 (Some(value), Some(id)) if id > 0 => Ok(Some((value, id))),
368 (Some(_), Some(_)) => Err(ApiError::new(format!(
369 "{value_name} cursor id must be positive",
370 ))),
371 _ => Err(ApiError::new(format!(
372 "{value_name} cursor requires both value and id",
373 ))),
374 }
375}
376
377pub fn parse_datetime_string_cursor(
383 value: Option<DateTime<Utc>>,
384 id: Option<String>,
385 value_name: &str,
386) -> Result<Option<(DateTime<Utc>, String)>> {
387 match (value, id) {
388 (None, None) => Ok(None),
389 (Some(value), Some(id)) if !id.trim().is_empty() => Ok(Some((value, id))),
390 (Some(_), Some(_)) => Err(ApiError::new(format!(
391 "{value_name} cursor id must not be empty",
392 ))),
393 _ => Err(ApiError::new(format!(
394 "{value_name} cursor requires both value and id",
395 ))),
396 }
397}
398
399pub fn parse_id_cursor(id: Option<i64>, value_name: &str) -> Result<Option<i64>> {
405 match id {
406 None => Ok(None),
407 Some(id) if id > 0 => Ok(Some(id)),
408 Some(_) => Err(ApiError::new(format!(
409 "{value_name} cursor id must be positive",
410 ))),
411 }
412}
413
414pub fn parse_string_id_cursor(
421 value: Option<String>,
422 id: Option<i64>,
423 value_name: &str,
424) -> Result<Option<(String, i64)>> {
425 match (value, id) {
426 (None, None) => Ok(None),
427 (Some(value), Some(id)) if !value.trim().is_empty() && id > 0 => Ok(Some((value, id))),
428 (Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
429 "{value_name} cursor id must be positive",
430 ))),
431 (Some(_), Some(_)) => Err(ApiError::new(format!(
432 "{value_name} cursor value must not be empty",
433 ))),
434 _ => Err(ApiError::new(format!(
435 "{value_name} cursor requires both value and id",
436 ))),
437 }
438}
439
440pub fn parse_sort_order_name_id_cursor(
446 sort_order: Option<i32>,
447 name: Option<String>,
448 id: Option<i64>,
449 value_name: &str,
450) -> Result<Option<(i32, String, i64)>> {
451 match (sort_order, name, id) {
452 (None, None, None) => Ok(None),
453 (Some(sort_order), Some(name), Some(id)) if !name.trim().is_empty() && id > 0 => {
454 Ok(Some((sort_order, name, id)))
455 }
456 (Some(_), Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
457 "{value_name} cursor id must be positive",
458 ))),
459 (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
460 "{value_name} cursor name must not be empty",
461 ))),
462 _ => Err(ApiError::new(format!(
463 "{value_name} cursor requires sort_order, name, and id",
464 ))),
465 }
466}
467
468pub fn parse_enabled_priority_id_cursor(
474 enabled: Option<bool>,
475 priority: Option<i32>,
476 id: Option<i64>,
477 value_name: &str,
478) -> Result<Option<(bool, i32, i64)>> {
479 match (enabled, priority, id) {
480 (None, None, None) => Ok(None),
481 (Some(enabled), Some(priority), Some(id)) if id > 0 => Ok(Some((enabled, priority, id))),
482 (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
483 "{value_name} cursor id must be positive",
484 ))),
485 _ => Err(ApiError::new(format!(
486 "{value_name} cursor requires enabled, priority, and id",
487 ))),
488 }
489}
490
491#[derive(Debug, Clone)]
493pub struct CursorSlice<T> {
494 pub items: Vec<T>,
496 pub total: u64,
498 pub has_more: bool,
500}
501
502impl<T> CursorSlice<T> {
503 #[must_use]
505 pub fn empty(total: u64) -> Self {
506 Self {
507 items: Vec::new(),
508 total,
509 has_more: false,
510 }
511 }
512
513 pub fn from_overfetch(mut items: Vec<T>, total: u64, limit: u64) -> Result<Self> {
520 let item_count = u64::try_from(items.len())
521 .map_err(|_| ApiError::new("cursor slice item count is too large"))?;
522 let has_more = item_count > limit;
523 if has_more {
524 let limit =
525 usize::try_from(limit).map_err(|_| ApiError::new("cursor limit is too large"))?;
526 items.truncate(limit);
527 }
528 Ok(Self {
529 items,
530 total,
531 has_more,
532 })
533 }
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
538#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
539#[serde(rename_all = "snake_case")]
540#[derive(Default)]
541pub enum SortOrder {
542 #[default]
544 Asc,
545 Desc,
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use serde_json::json;
553
554 #[test]
555 fn limit_offset_query_applies_defaults_and_bounds() {
556 let query = LimitOffsetQuery {
557 limit: None,
558 offset: None,
559 };
560 assert_eq!(query.limit_or(50, 100), 50);
561 assert_eq!(query.offset(), 0);
562
563 let query = LimitOffsetQuery {
564 limit: Some(0),
565 offset: Some(25),
566 };
567 assert_eq!(query.limit_or(50, 100), 1);
568 assert_eq!(query.offset(), 25);
569
570 let query = LimitOffsetQuery {
571 limit: Some(500),
572 offset: Some(10),
573 };
574 assert_eq!(query.limit_or(50, 100), 100);
575 }
576
577 #[test]
578 fn limit_query_applies_defaults_and_bounds() {
579 let query = LimitQuery { limit: None };
580 assert_eq!(query.limit_or(50, 100), 50);
581 assert_eq!(query.limit(), DEFAULT_PAGE_LIMIT);
582
583 let query = LimitQuery { limit: Some(0) };
584 assert_eq!(query.limit_or(50, 100), 1);
585
586 let query = LimitQuery { limit: Some(500) };
587 assert_eq!(query.limit_or(50, 100), 100);
588 }
589
590 #[derive(Debug, Deserialize, PartialEq, Eq)]
591 struct PatchDto {
592 #[serde(default)]
593 title: NullablePatch<String>,
594 #[serde(default, deserialize_with = "deserialize_nullable_patch_option")]
595 description: Option<NullablePatch<String>>,
596 }
597
598 #[test]
599 fn nullable_patch_deserializes_absent_null_and_value_fields() {
600 let dto: PatchDto = serde_json::from_value(json!({})).unwrap();
601 assert_eq!(dto.title, NullablePatch::Absent);
602 assert_eq!(dto.description, None);
603 assert!(!dto.title.is_present());
604
605 let dto: PatchDto =
606 serde_json::from_value(json!({ "title": null, "description": null })).unwrap();
607 assert_eq!(dto.title, NullablePatch::Null);
608 assert_eq!(dto.description, Some(NullablePatch::Null));
609 assert!(dto.title.is_present());
610
611 let dto: PatchDto = serde_json::from_value(json!({
612 "title": "new title",
613 "description": "new description"
614 }))
615 .unwrap();
616 assert_eq!(dto.title, NullablePatch::Value("new title".to_string()));
617 assert_eq!(
618 dto.description,
619 Some(NullablePatch::Value("new description".to_string()))
620 );
621 }
622
623 #[test]
624 fn offset_page_serializes_expected_shape() {
625 let page = OffsetPage::new(vec!["a", "b"], 10, 2, 4);
626 let value = serde_json::to_value(page).unwrap();
627
628 assert_eq!(
629 value,
630 json!({
631 "items": ["a", "b"],
632 "total": 10,
633 "limit": 2,
634 "offset": 4
635 })
636 );
637 }
638
639 #[test]
640 fn cursor_page_serializes_expected_shape() {
641 let page = CursorPage::new(vec!["a", "b"], 10, 2, Some(IdCursor { id: 42 }));
642 let value = serde_json::to_value(page).unwrap();
643
644 assert_eq!(
645 value,
646 json!({
647 "items": ["a", "b"],
648 "total": 10,
649 "limit": 2,
650 "next_cursor": { "id": 42 }
651 })
652 );
653 }
654
655 #[tokio::test]
656 async fn load_offset_page_clamps_limit_and_forwards_offset() {
657 let page = load_offset_page(500, 30, 100, |limit, offset| async move {
658 assert_eq!(limit, 100);
659 assert_eq!(offset, 30);
660 Ok((vec![1, 2, 3], 9))
661 })
662 .await
663 .unwrap();
664
665 assert_eq!(page.items, vec![1, 2, 3]);
666 assert_eq!(page.total, 9);
667 assert_eq!(page.limit, 100);
668 assert_eq!(page.offset, 30);
669 }
670
671 #[tokio::test]
672 async fn load_offset_page_propagates_fetch_error() {
673 let error = load_offset_page::<u8, _, _>(10, 0, 100, |_limit, _offset| async {
674 Err(ApiError::new("fetch failed"))
675 })
676 .await
677 .unwrap_err();
678
679 assert_eq!(error.message(), "fetch failed");
680 }
681
682 #[test]
683 fn sort_order_serializes_snake_case() {
684 assert_eq!(serde_json::to_value(SortOrder::Asc).unwrap(), json!("asc"));
685 assert_eq!(
686 serde_json::to_value(SortOrder::Desc).unwrap(),
687 json!("desc")
688 );
689 }
690
691 #[test]
692 fn parse_id_cursor_accepts_absent_or_positive_id() {
693 assert_eq!(parse_id_cursor(None, "profile").unwrap(), None);
694 assert_eq!(parse_id_cursor(Some(7), "profile").unwrap(), Some(7));
695
696 let error = parse_id_cursor(Some(0), "profile").unwrap_err();
697 assert_eq!(error.message(), "profile cursor id must be positive");
698 }
699
700 #[test]
701 fn parse_datetime_id_cursor_requires_both_parts() {
702 let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
703 .unwrap()
704 .with_timezone(&Utc);
705
706 assert_eq!(
707 parse_datetime_id_cursor(Some(timestamp), Some(9), "audit")
708 .unwrap()
709 .unwrap(),
710 (timestamp, 9)
711 );
712 assert_eq!(parse_datetime_id_cursor(None, None, "audit").unwrap(), None);
713
714 let error = parse_datetime_id_cursor(Some(timestamp), None, "audit").unwrap_err();
715 assert_eq!(error.message(), "audit cursor requires both value and id");
716
717 let error = parse_datetime_id_cursor(Some(timestamp), Some(-1), "audit").unwrap_err();
718 assert_eq!(error.message(), "audit cursor id must be positive");
719 }
720
721 #[test]
722 fn parse_datetime_string_cursor_rejects_empty_id() {
723 let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
724 .unwrap()
725 .with_timezone(&Utc);
726
727 assert_eq!(
728 parse_datetime_string_cursor(Some(timestamp), Some("abc".to_string()), "session")
729 .unwrap()
730 .unwrap(),
731 (timestamp, "abc".to_string())
732 );
733
734 let error = parse_datetime_string_cursor(Some(timestamp), Some(" ".to_string()), "session")
735 .unwrap_err();
736 assert_eq!(error.message(), "session cursor id must not be empty");
737 }
738
739 #[test]
740 fn parse_string_id_cursor_rejects_incomplete_or_empty_values() {
741 assert_eq!(
742 parse_string_id_cursor(Some("oauth".to_string()), Some(3), "provider")
743 .unwrap()
744 .unwrap(),
745 ("oauth".to_string(), 3)
746 );
747
748 let error = parse_string_id_cursor(Some(" ".to_string()), Some(3), "provider").unwrap_err();
749 assert_eq!(error.message(), "provider cursor value must not be empty");
750
751 let error =
752 parse_string_id_cursor(Some("oauth".to_string()), Some(0), "provider").unwrap_err();
753 assert_eq!(error.message(), "provider cursor id must be positive");
754
755 let error =
756 parse_string_id_cursor(Some("oauth".to_string()), None, "provider").unwrap_err();
757 assert_eq!(
758 error.message(),
759 "provider cursor requires both value and id"
760 );
761 }
762
763 #[test]
764 fn parse_sort_order_name_id_cursor_validates_tuple() {
765 assert_eq!(
766 parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), Some(2), "tag")
767 .unwrap()
768 .unwrap(),
769 (10, "cape".to_string(), 2)
770 );
771 assert_eq!(
772 parse_sort_order_name_id_cursor(None, None, None, "tag").unwrap(),
773 None
774 );
775
776 let error =
777 parse_sort_order_name_id_cursor(Some(10), Some(" ".to_string()), Some(2), "tag")
778 .unwrap_err();
779 assert_eq!(error.message(), "tag cursor name must not be empty");
780
781 let error =
782 parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), None, "tag")
783 .unwrap_err();
784 assert_eq!(
785 error.message(),
786 "tag cursor requires sort_order, name, and id"
787 );
788 }
789
790 #[test]
791 fn parse_enabled_priority_id_cursor_validates_tuple() {
792 assert_eq!(
793 parse_enabled_priority_id_cursor(Some(true), Some(10), Some(2), "server")
794 .unwrap()
795 .unwrap(),
796 (true, 10, 2)
797 );
798 assert_eq!(
799 parse_enabled_priority_id_cursor(None, None, None, "server").unwrap(),
800 None
801 );
802
803 let error =
804 parse_enabled_priority_id_cursor(Some(true), Some(10), Some(0), "server").unwrap_err();
805 assert_eq!(error.message(), "server cursor id must be positive");
806
807 let error =
808 parse_enabled_priority_id_cursor(Some(true), Some(10), None, "server").unwrap_err();
809 assert_eq!(
810 error.message(),
811 "server cursor requires enabled, priority, and id"
812 );
813 }
814
815 #[test]
816 fn cursor_slice_trims_overfetch_and_reports_has_more() {
817 let slice = CursorSlice::from_overfetch(vec![1, 2, 3], 10, 2).unwrap();
818 assert_eq!(slice.items, vec![1, 2]);
819 assert_eq!(slice.total, 10);
820 assert!(slice.has_more);
821
822 let slice = CursorSlice::from_overfetch(vec![1, 2], 2, 2).unwrap();
823 assert_eq!(slice.items, vec![1, 2]);
824 assert_eq!(slice.total, 2);
825 assert!(!slice.has_more);
826
827 let slice = CursorSlice::<u8>::empty(7);
828 assert!(slice.items.is_empty());
829 assert_eq!(slice.total, 7);
830 assert!(!slice.has_more);
831 }
832}