1#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9#![cfg_attr(
10 not(test),
11 deny(
12 clippy::unwrap_used,
13 clippy::unreachable,
14 clippy::expect_used,
15 clippy::panic,
16 clippy::unimplemented,
17 clippy::todo
18 )
19)]
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::future::Future;
24#[cfg(all(debug_assertions, feature = "openapi"))]
25use utoipa::{IntoParams, ToSchema};
26
27pub const DEFAULT_FOLDER_LIMIT: u64 = 200;
29pub const DEFAULT_FILE_LIMIT: u64 = 100;
31pub const DEFAULT_PAGE_LIMIT: u64 = 100;
33pub const MAX_PAGE_SIZE: u64 = 1000;
35
36pub type Result<T> = std::result::Result<T, ApiError>;
38
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41#[error("{message}")]
42pub struct ApiError {
43 message: String,
44}
45
46impl ApiError {
47 pub fn new(message: impl Into<String>) -> Self {
49 Self {
50 message: message.into(),
51 }
52 }
53
54 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 pub fn limit_or(&self, default: u64, max: u64) -> u64 {
76 self.limit.map(|v| v.clamp(1, max)).unwrap_or(default)
77 }
78
79 pub fn offset(&self) -> u64 {
81 self.offset.unwrap_or(0)
82 }
83}
84
85#[derive(Debug, Clone, Copy, Default, Deserialize)]
87#[cfg_attr(
88 all(debug_assertions, feature = "openapi"),
89 derive(IntoParams, ToSchema)
90)]
91pub struct LimitQuery {
92 pub limit: Option<u64>,
94}
95
96impl LimitQuery {
97 pub fn limit_or(&self, default: u64, max: u64) -> u64 {
99 self.limit
100 .map(|value| value.clamp(1, max))
101 .unwrap_or(default)
102 }
103
104 pub fn limit(&self) -> u64 {
106 self.limit_or(DEFAULT_PAGE_LIMIT, MAX_PAGE_SIZE)
107 }
108}
109
110#[derive(Debug, Clone, Copy, Default, Deserialize)]
112#[cfg_attr(
113 all(debug_assertions, feature = "openapi"),
114 derive(IntoParams, ToSchema)
115)]
116pub struct CreatedAtCursorQuery {
117 pub after_created_at: Option<DateTime<Utc>>,
119 pub after_id: Option<i64>,
121}
122
123#[derive(Debug, Clone, Copy, Default, Deserialize)]
125#[cfg_attr(
126 all(debug_assertions, feature = "openapi"),
127 derive(IntoParams, ToSchema)
128)]
129pub struct UpdatedAtCursorQuery {
130 pub after_updated_at: Option<DateTime<Utc>>,
132 pub after_id: Option<i64>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum NullablePatch<T> {
143 #[default]
145 Absent,
146 Null,
148 Value(T),
150}
151
152impl<T> NullablePatch<T> {
153 pub fn is_present(&self) -> bool {
155 !matches!(self, Self::Absent)
156 }
157}
158
159pub fn deserialize_nullable_patch_option<'de, D, T>(
164 deserializer: D,
165) -> std::result::Result<Option<NullablePatch<T>>, D::Error>
166where
167 D: serde::Deserializer<'de>,
168 T: Deserialize<'de>,
169{
170 Option::<T>::deserialize(deserializer).map(|value| Some(NullablePatch::from(value)))
171}
172
173impl<T> From<Option<T>> for NullablePatch<T> {
174 fn from(value: Option<T>) -> Self {
175 match value {
176 Some(value) => Self::Value(value),
177 None => Self::Null,
178 }
179 }
180}
181
182impl<'de, T> Deserialize<'de> for NullablePatch<T>
183where
184 T: Deserialize<'de>,
185{
186 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
187 where
188 D: serde::Deserializer<'de>,
189 {
190 Ok(match Option::<T>::deserialize(deserializer)? {
191 Some(value) => Self::Value(value),
192 None => Self::Null,
193 })
194 }
195}
196
197#[cfg(all(debug_assertions, feature = "openapi"))]
198#[doc(hidden)]
199pub trait ApiSchema: ToSchema {}
200
201#[cfg(all(debug_assertions, feature = "openapi"))]
202impl<T: ToSchema> ApiSchema for T {}
203
204#[cfg(not(all(debug_assertions, feature = "openapi")))]
205#[doc(hidden)]
206pub trait ApiSchema {}
207
208#[cfg(not(all(debug_assertions, feature = "openapi")))]
209impl<T> ApiSchema for T {}
210
211#[derive(Debug, Clone, Serialize)]
213#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
214pub struct OffsetPage<T: Serialize + ApiSchema> {
215 pub items: Vec<T>,
217 pub total: u64,
219 pub limit: u64,
221 pub offset: u64,
223}
224
225impl<T: Serialize + ApiSchema> OffsetPage<T> {
226 pub fn new(items: Vec<T>, total: u64, limit: u64, offset: u64) -> Self {
228 Self {
229 items,
230 total,
231 limit,
232 offset,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize)]
239#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
240pub struct CursorPage<T: Serialize + ApiSchema, C: Serialize + ApiSchema> {
241 pub items: Vec<T>,
243 pub total: u64,
245 pub limit: u64,
247 pub next_cursor: Option<C>,
249}
250
251impl<T: Serialize + ApiSchema, C: Serialize + ApiSchema> CursorPage<T, C> {
252 pub fn new(items: Vec<T>, total: u64, limit: u64, next_cursor: Option<C>) -> Self {
254 Self {
255 items,
256 total,
257 limit,
258 next_cursor,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
266pub struct IdCursor {
267 pub id: i64,
269}
270
271#[derive(Debug, Clone, Serialize)]
273#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
274pub struct StringIdCursor {
275 pub value: String,
277 pub id: i64,
279}
280
281#[derive(Debug, Clone, Serialize)]
283#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
284pub struct SortOrderNameIdCursor {
285 pub sort_order: i32,
287 pub name: String,
289 pub id: i64,
291}
292
293#[derive(Debug, Clone, Serialize)]
295#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
296pub struct EnabledPriorityIdCursor {
297 pub enabled: bool,
299 pub priority: i32,
301 pub id: i64,
303}
304
305#[derive(Debug, Clone, Serialize)]
307#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
308pub struct DateTimeIdCursor {
309 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
311 pub value: DateTime<Utc>,
312 pub id: i64,
314}
315
316#[derive(Debug, Clone, Serialize)]
318#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
319pub struct DateTimeStringCursor {
320 #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
322 pub value: DateTime<Utc>,
323 pub id: String,
325}
326
327pub async fn load_offset_page<T, F, Fut>(
329 limit: u64,
330 offset: u64,
331 max_limit: u64,
332 fetch: F,
333) -> Result<OffsetPage<T>>
334where
335 T: Serialize + ApiSchema,
336 F: FnOnce(u64, u64) -> Fut,
337 Fut: Future<Output = Result<(Vec<T>, u64)>>,
338{
339 let limit = limit.clamp(1, max_limit);
340 let (items, total) = fetch(limit, offset).await?;
341 Ok(OffsetPage::new(items, total, limit, offset))
342}
343
344pub fn parse_datetime_id_cursor(
346 value: Option<DateTime<Utc>>,
347 id: Option<i64>,
348 value_name: &str,
349) -> Result<Option<(DateTime<Utc>, i64)>> {
350 match (value, id) {
351 (None, None) => Ok(None),
352 (Some(value), Some(id)) if id > 0 => Ok(Some((value, id))),
353 (Some(_), Some(_)) => Err(ApiError::new(format!(
354 "{value_name} cursor id must be positive",
355 ))),
356 _ => Err(ApiError::new(format!(
357 "{value_name} cursor requires both value and id",
358 ))),
359 }
360}
361
362pub fn parse_datetime_string_cursor(
364 value: Option<DateTime<Utc>>,
365 id: Option<String>,
366 value_name: &str,
367) -> Result<Option<(DateTime<Utc>, String)>> {
368 match (value, id) {
369 (None, None) => Ok(None),
370 (Some(value), Some(id)) if !id.trim().is_empty() => Ok(Some((value, id))),
371 (Some(_), Some(_)) => Err(ApiError::new(format!(
372 "{value_name} cursor id must not be empty",
373 ))),
374 _ => Err(ApiError::new(format!(
375 "{value_name} cursor requires both value and id",
376 ))),
377 }
378}
379
380pub fn parse_id_cursor(id: Option<i64>, value_name: &str) -> Result<Option<i64>> {
382 match id {
383 None => Ok(None),
384 Some(id) if id > 0 => Ok(Some(id)),
385 Some(_) => Err(ApiError::new(format!(
386 "{value_name} cursor id must be positive",
387 ))),
388 }
389}
390
391pub fn parse_string_id_cursor(
393 value: Option<String>,
394 id: Option<i64>,
395 value_name: &str,
396) -> Result<Option<(String, i64)>> {
397 match (value, id) {
398 (None, None) => Ok(None),
399 (Some(value), Some(id)) if !value.trim().is_empty() && id > 0 => Ok(Some((value, id))),
400 (Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
401 "{value_name} cursor id must be positive",
402 ))),
403 (Some(_), Some(_)) => Err(ApiError::new(format!(
404 "{value_name} cursor value must not be empty",
405 ))),
406 _ => Err(ApiError::new(format!(
407 "{value_name} cursor requires both value and id",
408 ))),
409 }
410}
411
412pub fn parse_sort_order_name_id_cursor(
414 sort_order: Option<i32>,
415 name: Option<String>,
416 id: Option<i64>,
417 value_name: &str,
418) -> Result<Option<(i32, String, i64)>> {
419 match (sort_order, name, id) {
420 (None, None, None) => Ok(None),
421 (Some(sort_order), Some(name), Some(id)) if !name.trim().is_empty() && id > 0 => {
422 Ok(Some((sort_order, name, id)))
423 }
424 (Some(_), Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
425 "{value_name} cursor id must be positive",
426 ))),
427 (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
428 "{value_name} cursor name must not be empty",
429 ))),
430 _ => Err(ApiError::new(format!(
431 "{value_name} cursor requires sort_order, name, and id",
432 ))),
433 }
434}
435
436pub fn parse_enabled_priority_id_cursor(
438 enabled: Option<bool>,
439 priority: Option<i32>,
440 id: Option<i64>,
441 value_name: &str,
442) -> Result<Option<(bool, i32, i64)>> {
443 match (enabled, priority, id) {
444 (None, None, None) => Ok(None),
445 (Some(enabled), Some(priority), Some(id)) if id > 0 => Ok(Some((enabled, priority, id))),
446 (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
447 "{value_name} cursor id must be positive",
448 ))),
449 _ => Err(ApiError::new(format!(
450 "{value_name} cursor requires enabled, priority, and id",
451 ))),
452 }
453}
454
455#[derive(Debug, Clone)]
457pub struct CursorSlice<T> {
458 pub items: Vec<T>,
460 pub total: u64,
462 pub has_more: bool,
464}
465
466impl<T> CursorSlice<T> {
467 pub fn empty(total: u64) -> Self {
469 Self {
470 items: Vec::new(),
471 total,
472 has_more: false,
473 }
474 }
475
476 pub fn from_overfetch(mut items: Vec<T>, total: u64, limit: u64) -> Result<Self> {
478 let item_count = u64::try_from(items.len())
479 .map_err(|_| ApiError::new("cursor slice item count is too large"))?;
480 let has_more = item_count > limit;
481 if has_more {
482 let limit =
483 usize::try_from(limit).map_err(|_| ApiError::new("cursor limit is too large"))?;
484 items.truncate(limit);
485 }
486 Ok(Self {
487 items,
488 total,
489 has_more,
490 })
491 }
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
497#[serde(rename_all = "snake_case")]
498#[derive(Default)]
499pub enum SortOrder {
500 #[default]
502 Asc,
503 Desc,
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use serde_json::json;
511
512 #[test]
513 fn limit_offset_query_applies_defaults_and_bounds() {
514 let query = LimitOffsetQuery {
515 limit: None,
516 offset: None,
517 };
518 assert_eq!(query.limit_or(50, 100), 50);
519 assert_eq!(query.offset(), 0);
520
521 let query = LimitOffsetQuery {
522 limit: Some(0),
523 offset: Some(25),
524 };
525 assert_eq!(query.limit_or(50, 100), 1);
526 assert_eq!(query.offset(), 25);
527
528 let query = LimitOffsetQuery {
529 limit: Some(500),
530 offset: Some(10),
531 };
532 assert_eq!(query.limit_or(50, 100), 100);
533 }
534
535 #[test]
536 fn limit_query_applies_defaults_and_bounds() {
537 let query = LimitQuery { limit: None };
538 assert_eq!(query.limit_or(50, 100), 50);
539 assert_eq!(query.limit(), DEFAULT_PAGE_LIMIT);
540
541 let query = LimitQuery { limit: Some(0) };
542 assert_eq!(query.limit_or(50, 100), 1);
543
544 let query = LimitQuery { limit: Some(500) };
545 assert_eq!(query.limit_or(50, 100), 100);
546 }
547
548 #[derive(Debug, Deserialize, PartialEq, Eq)]
549 struct PatchDto {
550 #[serde(default)]
551 title: NullablePatch<String>,
552 #[serde(default, deserialize_with = "deserialize_nullable_patch_option")]
553 description: Option<NullablePatch<String>>,
554 }
555
556 #[test]
557 fn nullable_patch_deserializes_absent_null_and_value_fields() {
558 let dto: PatchDto = serde_json::from_value(json!({})).unwrap();
559 assert_eq!(dto.title, NullablePatch::Absent);
560 assert_eq!(dto.description, None);
561 assert!(!dto.title.is_present());
562
563 let dto: PatchDto =
564 serde_json::from_value(json!({ "title": null, "description": null })).unwrap();
565 assert_eq!(dto.title, NullablePatch::Null);
566 assert_eq!(dto.description, Some(NullablePatch::Null));
567 assert!(dto.title.is_present());
568
569 let dto: PatchDto = serde_json::from_value(json!({
570 "title": "new title",
571 "description": "new description"
572 }))
573 .unwrap();
574 assert_eq!(dto.title, NullablePatch::Value("new title".to_string()));
575 assert_eq!(
576 dto.description,
577 Some(NullablePatch::Value("new description".to_string()))
578 );
579 }
580
581 #[test]
582 fn offset_page_serializes_expected_shape() {
583 let page = OffsetPage::new(vec!["a", "b"], 10, 2, 4);
584 let value = serde_json::to_value(page).unwrap();
585
586 assert_eq!(
587 value,
588 json!({
589 "items": ["a", "b"],
590 "total": 10,
591 "limit": 2,
592 "offset": 4
593 })
594 );
595 }
596
597 #[test]
598 fn cursor_page_serializes_expected_shape() {
599 let page = CursorPage::new(vec!["a", "b"], 10, 2, Some(IdCursor { id: 42 }));
600 let value = serde_json::to_value(page).unwrap();
601
602 assert_eq!(
603 value,
604 json!({
605 "items": ["a", "b"],
606 "total": 10,
607 "limit": 2,
608 "next_cursor": { "id": 42 }
609 })
610 );
611 }
612
613 #[tokio::test]
614 async fn load_offset_page_clamps_limit_and_forwards_offset() {
615 let page = load_offset_page(500, 30, 100, |limit, offset| async move {
616 assert_eq!(limit, 100);
617 assert_eq!(offset, 30);
618 Ok((vec![1, 2, 3], 9))
619 })
620 .await
621 .unwrap();
622
623 assert_eq!(page.items, vec![1, 2, 3]);
624 assert_eq!(page.total, 9);
625 assert_eq!(page.limit, 100);
626 assert_eq!(page.offset, 30);
627 }
628
629 #[tokio::test]
630 async fn load_offset_page_propagates_fetch_error() {
631 let error = load_offset_page::<u8, _, _>(10, 0, 100, |_limit, _offset| async {
632 Err(ApiError::new("fetch failed"))
633 })
634 .await
635 .unwrap_err();
636
637 assert_eq!(error.message(), "fetch failed");
638 }
639
640 #[test]
641 fn sort_order_serializes_snake_case() {
642 assert_eq!(serde_json::to_value(SortOrder::Asc).unwrap(), json!("asc"));
643 assert_eq!(
644 serde_json::to_value(SortOrder::Desc).unwrap(),
645 json!("desc")
646 );
647 }
648
649 #[test]
650 fn parse_id_cursor_accepts_absent_or_positive_id() {
651 assert_eq!(parse_id_cursor(None, "profile").unwrap(), None);
652 assert_eq!(parse_id_cursor(Some(7), "profile").unwrap(), Some(7));
653
654 let error = parse_id_cursor(Some(0), "profile").unwrap_err();
655 assert_eq!(error.message(), "profile cursor id must be positive");
656 }
657
658 #[test]
659 fn parse_datetime_id_cursor_requires_both_parts() {
660 let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
661 .unwrap()
662 .with_timezone(&Utc);
663
664 assert_eq!(
665 parse_datetime_id_cursor(Some(timestamp), Some(9), "audit")
666 .unwrap()
667 .unwrap(),
668 (timestamp, 9)
669 );
670 assert_eq!(parse_datetime_id_cursor(None, None, "audit").unwrap(), None);
671
672 let error = parse_datetime_id_cursor(Some(timestamp), None, "audit").unwrap_err();
673 assert_eq!(error.message(), "audit cursor requires both value and id");
674
675 let error = parse_datetime_id_cursor(Some(timestamp), Some(-1), "audit").unwrap_err();
676 assert_eq!(error.message(), "audit cursor id must be positive");
677 }
678
679 #[test]
680 fn parse_datetime_string_cursor_rejects_empty_id() {
681 let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
682 .unwrap()
683 .with_timezone(&Utc);
684
685 assert_eq!(
686 parse_datetime_string_cursor(Some(timestamp), Some("abc".to_string()), "session")
687 .unwrap()
688 .unwrap(),
689 (timestamp, "abc".to_string())
690 );
691
692 let error = parse_datetime_string_cursor(Some(timestamp), Some(" ".to_string()), "session")
693 .unwrap_err();
694 assert_eq!(error.message(), "session cursor id must not be empty");
695 }
696
697 #[test]
698 fn parse_string_id_cursor_rejects_incomplete_or_empty_values() {
699 assert_eq!(
700 parse_string_id_cursor(Some("oauth".to_string()), Some(3), "provider")
701 .unwrap()
702 .unwrap(),
703 ("oauth".to_string(), 3)
704 );
705
706 let error = parse_string_id_cursor(Some(" ".to_string()), Some(3), "provider").unwrap_err();
707 assert_eq!(error.message(), "provider cursor value must not be empty");
708
709 let error =
710 parse_string_id_cursor(Some("oauth".to_string()), Some(0), "provider").unwrap_err();
711 assert_eq!(error.message(), "provider cursor id must be positive");
712
713 let error =
714 parse_string_id_cursor(Some("oauth".to_string()), None, "provider").unwrap_err();
715 assert_eq!(
716 error.message(),
717 "provider cursor requires both value and id"
718 );
719 }
720
721 #[test]
722 fn parse_sort_order_name_id_cursor_validates_tuple() {
723 assert_eq!(
724 parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), Some(2), "tag")
725 .unwrap()
726 .unwrap(),
727 (10, "cape".to_string(), 2)
728 );
729 assert_eq!(
730 parse_sort_order_name_id_cursor(None, None, None, "tag").unwrap(),
731 None
732 );
733
734 let error =
735 parse_sort_order_name_id_cursor(Some(10), Some(" ".to_string()), Some(2), "tag")
736 .unwrap_err();
737 assert_eq!(error.message(), "tag cursor name must not be empty");
738
739 let error =
740 parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), None, "tag")
741 .unwrap_err();
742 assert_eq!(
743 error.message(),
744 "tag cursor requires sort_order, name, and id"
745 );
746 }
747
748 #[test]
749 fn parse_enabled_priority_id_cursor_validates_tuple() {
750 assert_eq!(
751 parse_enabled_priority_id_cursor(Some(true), Some(10), Some(2), "server")
752 .unwrap()
753 .unwrap(),
754 (true, 10, 2)
755 );
756 assert_eq!(
757 parse_enabled_priority_id_cursor(None, None, None, "server").unwrap(),
758 None
759 );
760
761 let error =
762 parse_enabled_priority_id_cursor(Some(true), Some(10), Some(0), "server").unwrap_err();
763 assert_eq!(error.message(), "server cursor id must be positive");
764
765 let error =
766 parse_enabled_priority_id_cursor(Some(true), Some(10), None, "server").unwrap_err();
767 assert_eq!(
768 error.message(),
769 "server cursor requires enabled, priority, and id"
770 );
771 }
772
773 #[test]
774 fn cursor_slice_trims_overfetch_and_reports_has_more() {
775 let slice = CursorSlice::from_overfetch(vec![1, 2, 3], 10, 2).unwrap();
776 assert_eq!(slice.items, vec![1, 2]);
777 assert_eq!(slice.total, 10);
778 assert!(slice.has_more);
779
780 let slice = CursorSlice::from_overfetch(vec![1, 2], 2, 2).unwrap();
781 assert_eq!(slice.items, vec![1, 2]);
782 assert_eq!(slice.total, 2);
783 assert!(!slice.has_more);
784
785 let slice = CursorSlice::<u8>::empty(7);
786 assert!(slice.items.is_empty());
787 assert_eq!(slice.total, 7);
788 assert!(!slice.has_more);
789 }
790}