1use axum::extract::FromRequestParts;
147use axum::http::request::Parts;
148use axum::http::{HeaderValue, header};
149use axum::response::{IntoResponse, Response};
150use serde::de::DeserializeOwned;
151use serde::{Deserialize, Serialize};
152
153pub const DEFAULT_PAGE_SIZE: u32 = 20;
155
156pub const MAX_PAGE_SIZE: u32 = 100;
159
160#[derive(Debug, Clone, Copy, Default, Deserialize)]
208pub struct PageRequest {
209 #[serde(default)]
210 page: Option<u32>,
211 #[serde(default)]
212 size: Option<u32>,
213}
214
215impl PageRequest {
216 #[must_use]
219 pub const fn new(page: u32, size: u32) -> Self {
220 Self {
221 page: Some(page),
222 size: Some(size),
223 }
224 }
225
226 #[must_use]
228 pub const fn page(&self) -> u32 {
229 match self.page {
230 Some(p) if p >= 1 => p,
231 _ => 1,
232 }
233 }
234
235 #[must_use]
237 pub const fn size(&self) -> u32 {
238 match self.size {
239 Some(0) | None => DEFAULT_PAGE_SIZE,
240 Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
241 Some(s) => s,
242 }
243 }
244
245 #[must_use]
247 pub const fn limit(&self) -> i64 {
248 self.size() as i64
249 }
250
251 #[must_use]
253 pub const fn offset(&self) -> i64 {
254 ((self.page() - 1) as i64) * (self.size() as i64)
255 }
256}
257
258impl<S> FromRequestParts<S> for PageRequest
259where
260 S: Send + Sync,
261{
262 type Rejection = std::convert::Infallible;
263
264 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
265 Ok(parts.uri.query().map_or_else(Self::default, parse_query))
270 }
271}
272
273fn parse_query(query: &str) -> PageRequest {
278 let mut req = PageRequest::default();
279 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
280 match key.as_ref() {
281 "page" => {
282 if let Ok(n) = value.parse::<u32>() {
283 req.page = Some(n);
284 }
285 }
286 "size" => {
287 if let Ok(n) = value.parse::<u32>() {
288 req.size = Some(n);
289 }
290 }
291 _ => {}
292 }
293 }
294 req
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum SortDir {
311 #[default]
313 Asc,
314 Desc,
316}
317
318impl SortDir {
319 #[must_use]
321 pub const fn param_value(self) -> &'static str {
322 match self {
323 Self::Asc => "asc",
324 Self::Desc => "desc",
325 }
326 }
327
328 #[must_use]
330 pub const fn aria_value(self) -> &'static str {
331 match self {
332 Self::Asc => "ascending",
333 Self::Desc => "descending",
334 }
335 }
336
337 #[must_use]
339 pub const fn toggled(self) -> Self {
340 match self {
341 Self::Asc => Self::Desc,
342 Self::Desc => Self::Asc,
343 }
344 }
345
346 #[must_use]
351 pub const fn from_param(value: &str) -> Self {
352 if value.eq_ignore_ascii_case("desc") {
353 Self::Desc
354 } else {
355 Self::Asc
356 }
357 }
358}
359
360#[derive(Debug, Clone, Default)]
410pub struct ListQuery {
411 sort: Option<String>,
412 dir: SortDir,
413 filters: Vec<(String, String)>,
414}
415
416impl ListQuery {
417 #[must_use]
420 pub fn new(sort: Option<&str>, dir: SortDir, filters: &[(&str, &str)]) -> Self {
421 Self {
422 sort: sort.map(str::to_owned),
423 dir,
424 filters: filters
425 .iter()
426 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
427 .collect(),
428 }
429 }
430
431 #[must_use]
438 pub fn sort(&self) -> Option<&str> {
439 self.sort.as_deref()
440 }
441
442 #[must_use]
444 pub const fn direction(&self) -> SortDir {
445 self.dir
446 }
447
448 pub fn filters(&self) -> impl Iterator<Item = (&str, &str)> {
454 self.filters.iter().map(|(k, v)| (k.as_str(), v.as_str()))
455 }
456
457 #[must_use]
459 pub fn is_empty(&self) -> bool {
460 self.sort.is_none() && self.dir == SortDir::Asc && self.filters.is_empty()
461 }
462}
463
464#[doc(hidden)]
476pub trait AutumnListable {
477 #[doc(hidden)]
478 fn __autumn_list_apply_order<Q>(query_builder: Q, _list: &ListQuery) -> Q {
479 query_builder
480 }
481 #[doc(hidden)]
482 fn __autumn_list_apply_filters<Q>(query_builder: Q, _list: &ListQuery) -> Q {
483 query_builder
484 }
485}
486
487impl<T> AutumnListable for T {}
488
489impl<S> FromRequestParts<S> for ListQuery
490where
491 S: Send + Sync,
492{
493 type Rejection = std::convert::Infallible;
494
495 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
496 Ok(parts
499 .uri
500 .query()
501 .map_or_else(Self::default, parse_list_query))
502 }
503}
504
505fn parse_list_query(query: &str) -> ListQuery {
517 let mut req = ListQuery::default();
518 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
519 let key = key.as_ref();
520 if key == "sort" {
521 if value.is_empty() {
522 req.sort = None;
523 } else {
524 req.sort = Some(value.into_owned());
525 }
526 } else if key == "dir" {
527 req.dir = SortDir::from_param(&value);
528 } else if let Some(col) = key
529 .strip_prefix("filter[")
530 .and_then(|rest| rest.strip_suffix(']'))
531 && !col.is_empty()
532 {
533 req.filters.push((col.to_owned(), value.into_owned()));
534 }
535 }
536 req
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct Page<T> {
565 pub content: Vec<T>,
567 pub page: u32,
569 pub size: u32,
571 pub total_elements: u64,
573 pub total_pages: u32,
575 pub has_next: bool,
577 pub has_previous: bool,
579}
580
581impl<T> Page<T> {
582 #[must_use]
588 pub fn new(items: Vec<T>, total: i64, request: &PageRequest) -> Self {
589 let size = request.size();
590 let page = request.page();
591 let total_elements = u64::try_from(total).unwrap_or(0);
592
593 let total_pages = if total_elements == 0 {
597 1
598 } else {
599 u32::try_from(total_elements.div_ceil(u64::from(size))).unwrap_or(u32::MAX)
601 };
602
603 Self {
604 content: items,
605 page,
606 size,
607 total_elements,
608 total_pages,
609 has_next: page < total_pages,
610 has_previous: page > 1,
611 }
612 }
613
614 #[must_use]
618 pub fn empty(request: &PageRequest) -> Self {
619 Self::new(Vec::new(), 0, request)
620 }
621
622 #[must_use]
630 pub fn from_raw(page: u32, size: u32, total_elements: u64, total_pages: u32) -> Self {
631 let page = page.max(1);
632 let total_pages = total_pages.max(1);
633 Self {
634 content: Vec::new(),
635 page,
636 size,
637 total_elements,
638 total_pages,
639 has_next: page < total_pages,
640 has_previous: page > 1,
641 }
642 }
643
644 #[must_use]
657 pub fn paginate_in_memory(all: Vec<T>, request: &PageRequest) -> Self {
658 let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
659 let offset = usize::try_from(request.offset()).unwrap_or(usize::MAX);
660 let size = request.size() as usize;
661 let content: Vec<T> = all.into_iter().skip(offset).take(size).collect();
662 Self::new(content, total, request)
663 }
664
665 pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
670 Page {
671 content: self.content.into_iter().map(f).collect(),
672 page: self.page,
673 size: self.size,
674 total_elements: self.total_elements,
675 total_pages: self.total_pages,
676 has_next: self.has_next,
677 has_previous: self.has_previous,
678 }
679 }
680}
681
682fn page_link_header_value(page: u32, size: u32, total_pages: u32, has_next: bool) -> String {
689 let mut links: Vec<String> = Vec::with_capacity(4);
690 let rel = |target: u32, name: &str| format!("<?page={target}&size={size}>; rel=\"{name}\"");
691 links.push(rel(1, "first"));
692 if page > 1 {
693 links.push(rel(page - 1, "prev"));
694 }
695 if has_next {
696 links.push(rel(page + 1, "next"));
697 }
698 let last_page = total_pages.max(1);
701 links.push(rel(last_page, "last"));
702 links.join(", ")
703}
704
705impl<T> IntoResponse for Page<T>
706where
707 T: Serialize,
708{
709 fn into_response(self) -> Response {
710 let (page, size, total_pages, has_next) =
712 (self.page, self.size, self.total_pages, self.has_next);
713 let mut response = axum::Json(self).into_response();
714 if let Ok(value) =
715 HeaderValue::from_str(&page_link_header_value(page, size, total_pages, has_next))
716 {
717 response.headers_mut().insert(header::LINK, value);
718 }
719 response
720 }
721}
722
723impl<T> IntoResponse for CursorPage<T>
724where
725 T: Serialize,
726{
727 fn into_response(self) -> Response {
728 let next_link = self
731 .next_cursor
732 .as_ref()
733 .filter(|_| self.has_next)
734 .map(|token| {
735 let encoded: String =
736 url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
737 format!("<?cursor={encoded}&size={}>; rel=\"next\"", self.size)
738 });
739 let mut response = axum::Json(self).into_response();
740 if let Some(link) = next_link
741 && let Ok(value) = HeaderValue::from_str(&link)
742 {
743 response.headers_mut().insert(header::LINK, value);
744 }
745 response
746 }
747}
748
749const BASE64URL_ALPHABET: &[u8; 64] =
759 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
760
761fn base64url_encode(input: &[u8]) -> String {
762 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
763 let (chunks, rem) = input.as_chunks::<3>();
764 for chunk in chunks {
765 let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
766 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
767 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
768 out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
769 out.push(BASE64URL_ALPHABET[(n & 0x3F) as usize] as char);
770 }
771 match rem.len() {
772 0 => {}
773 1 => {
774 let n = u32::from(rem[0]) << 16;
775 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
776 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
777 }
778 2 => {
779 let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
780 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
781 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
782 out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
783 }
784 _ => unreachable!("as_chunks remainder is < 3 by construction"),
785 }
786 out
787}
788
789fn base64url_decode(input: &str) -> Option<Vec<u8>> {
790 let bytes = input.as_bytes();
791 let value = |b: u8| -> Option<u32> {
792 match b {
793 b'A'..=b'Z' => Some(u32::from(b - b'A')),
794 b'a'..=b'z' => Some(u32::from(b - b'a') + 26),
795 b'0'..=b'9' => Some(u32::from(b - b'0') + 52),
796 b'-' => Some(62),
797 b'_' => Some(63),
798 _ => None,
799 }
800 };
801 let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
802 let mut i = 0;
803 while i + 4 <= bytes.len() {
804 let n = (value(bytes[i])? << 18)
805 | (value(bytes[i + 1])? << 12)
806 | (value(bytes[i + 2])? << 6)
807 | value(bytes[i + 3])?;
808 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
809 out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
810 out.push(u8::try_from(n & 0xFF).ok()?);
811 i += 4;
812 }
813 match bytes.len() - i {
814 0 => {}
815 1 => return None, 2 => {
817 let n = (value(bytes[i])? << 18) | (value(bytes[i + 1])? << 12);
818 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
819 }
820 3 => {
821 let n = (value(bytes[i])? << 18)
822 | (value(bytes[i + 1])? << 12)
823 | (value(bytes[i + 2])? << 6);
824 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
825 out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
826 }
827 _ => unreachable!("bytes.len() - i is < 4 by the loop condition"),
828 }
829 Some(out)
830}
831
832pub struct Cursor;
853
854impl Cursor {
855 pub fn encode<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
861 Ok(base64url_encode(&serde_json::to_vec(value)?))
862 }
863
864 #[must_use]
872 pub fn decode<T: DeserializeOwned>(token: &str) -> Option<T> {
873 let bytes = base64url_decode(token)?;
874 serde_json::from_slice(&bytes).ok()
875 }
876
877 pub fn encode_signed<T: Serialize>(value: &T, key: &[u8]) -> Result<String, serde_json::Error> {
895 let payload = serde_json::to_vec(value)?;
896 let payload_b64 = base64url_encode(&payload);
897 let mac = hmac_sha256(key, payload_b64.as_bytes());
898 let sig_b64 = base64url_encode(&mac);
899 Ok(format!("{payload_b64}.{sig_b64}"))
900 }
901
902 #[must_use]
912 pub fn decode_signed<T: DeserializeOwned>(token: &str, key: &[u8]) -> Option<T> {
913 let (payload_b64, sig_b64) = token.split_once('.')?;
914 let expected_sig = base64url_decode(sig_b64)?;
915 let actual_sig = hmac_sha256(key, payload_b64.as_bytes());
916 if !constant_time_eq(&expected_sig, &actual_sig) {
917 return None;
918 }
919 let payload = base64url_decode(payload_b64)?;
920 serde_json::from_slice(&payload).ok()
921 }
922}
923
924fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
925 use hmac::{Hmac, KeyInit, Mac};
926 use sha2::Sha256;
927 let mut mac =
929 <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
930 mac.update(message);
931 mac.finalize().into_bytes().into()
932}
933
934fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
935 use subtle::ConstantTimeEq;
936 a.ct_eq(b).into()
937}
938
939#[derive(Debug, Clone, Default, Deserialize)]
963pub struct CursorRequest {
964 #[serde(default)]
965 cursor: Option<String>,
966 #[serde(default)]
967 size: Option<u32>,
968}
969
970impl CursorRequest {
971 #[must_use]
973 pub const fn new(cursor: Option<String>, size: u32) -> Self {
974 Self {
975 cursor,
976 size: Some(size),
977 }
978 }
979
980 #[must_use]
982 pub fn cursor(&self) -> Option<&str> {
983 self.cursor.as_deref()
984 }
985
986 #[must_use]
991 pub fn decode<T: DeserializeOwned>(&self) -> Option<T> {
992 Cursor::decode(self.cursor.as_deref()?)
993 }
994
995 #[must_use]
1002 pub fn decode_signed<T: DeserializeOwned>(&self, key: &[u8]) -> Option<T> {
1003 Cursor::decode_signed(self.cursor.as_deref()?, key)
1004 }
1005
1006 #[must_use]
1008 pub const fn size(&self) -> u32 {
1009 match self.size {
1010 Some(0) | None => DEFAULT_PAGE_SIZE,
1011 Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
1012 Some(s) => s,
1013 }
1014 }
1015
1016 #[must_use]
1018 pub const fn limit(&self) -> i64 {
1019 self.size() as i64
1020 }
1021
1022 #[must_use]
1028 pub const fn fetch_limit(&self) -> i64 {
1029 self.size() as i64 + 1
1030 }
1031}
1032
1033impl<S> FromRequestParts<S> for CursorRequest
1034where
1035 S: Send + Sync,
1036{
1037 type Rejection = std::convert::Infallible;
1038
1039 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
1040 Ok(parts
1041 .uri
1042 .query()
1043 .map_or_else(Self::default, parse_cursor_query))
1044 }
1045}
1046
1047fn parse_cursor_query(query: &str) -> CursorRequest {
1052 let mut req = CursorRequest::default();
1053 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1054 match key.as_ref() {
1055 "cursor" if !value.is_empty() => {
1056 req.cursor = Some(value.into_owned());
1057 }
1058 "size" => {
1059 if let Ok(n) = value.parse::<u32>() {
1060 req.size = Some(n);
1061 }
1062 }
1063 _ => {}
1064 }
1065 }
1066 req
1067}
1068
1069#[derive(Debug, Clone, Serialize, Deserialize)]
1091pub struct CursorPage<T> {
1092 pub content: Vec<T>,
1094 pub size: u32,
1096 pub next_cursor: Option<String>,
1098 pub has_next: bool,
1100}
1101
1102impl<T> CursorPage<T> {
1103 #[must_use]
1123 pub fn from_overfetched<K, F>(items: Vec<T>, request: &CursorRequest, cursor_fn: F) -> Self
1124 where
1125 K: Serialize,
1126 F: FnOnce(&T) -> K,
1127 {
1128 Self::from_overfetched_inner(items, request, cursor_fn, |k| Cursor::encode(&k).ok())
1129 }
1130
1131 #[must_use]
1143 pub fn from_overfetched_signed<K, F>(
1144 items: Vec<T>,
1145 request: &CursorRequest,
1146 key: &[u8],
1147 cursor_fn: F,
1148 ) -> Self
1149 where
1150 K: Serialize,
1151 F: FnOnce(&T) -> K,
1152 {
1153 Self::from_overfetched_inner(items, request, cursor_fn, |k| {
1154 Cursor::encode_signed(&k, key).ok()
1155 })
1156 }
1157
1158 fn from_overfetched_inner<K, F, E>(
1159 mut items: Vec<T>,
1160 request: &CursorRequest,
1161 cursor_fn: F,
1162 encode: E,
1163 ) -> Self
1164 where
1165 K: Serialize,
1166 F: FnOnce(&T) -> K,
1167 E: FnOnce(K) -> Option<String>,
1168 {
1169 let size = request.size();
1170 let limit = size as usize;
1171 let has_next = items.len() > limit;
1172 if has_next {
1173 items.truncate(limit);
1174 }
1175 let next_cursor = if has_next {
1176 items.last().map(cursor_fn).and_then(encode)
1181 } else {
1182 None
1183 };
1184 let has_next = has_next && next_cursor.is_some();
1187 Self {
1188 content: items,
1189 size,
1190 next_cursor,
1191 has_next,
1192 }
1193 }
1194
1195 #[must_use]
1199 pub const fn empty(request: &CursorRequest) -> Self {
1200 Self {
1201 content: Vec::new(),
1202 size: request.size(),
1203 next_cursor: None,
1204 has_next: false,
1205 }
1206 }
1207
1208 pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPage<U> {
1210 CursorPage {
1211 content: self.content.into_iter().map(f).collect(),
1212 size: self.size,
1213 next_cursor: self.next_cursor,
1214 has_next: self.has_next,
1215 }
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use axum::Router;
1223 use axum::body::Body;
1224 use axum::http::{Request, StatusCode};
1225 use axum::routing::get;
1226 use tower::ServiceExt;
1227
1228 #[test]
1231 fn defaults_when_nothing_provided() {
1232 let r = PageRequest::default();
1233 assert_eq!(r.page(), 1);
1234 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1235 assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1236 assert_eq!(r.offset(), 0);
1237 }
1238
1239 #[test]
1240 fn page_zero_is_coerced_to_one() {
1241 let r = PageRequest::new(0, 10);
1242 assert_eq!(r.page(), 1);
1243 assert_eq!(r.offset(), 0);
1244 }
1245
1246 #[test]
1247 fn size_is_clamped_to_max() {
1248 let r = PageRequest::new(1, 9_999);
1249 assert_eq!(r.size(), MAX_PAGE_SIZE);
1250 assert_eq!(r.limit(), i64::from(MAX_PAGE_SIZE));
1251
1252 let exact = PageRequest::new(1, MAX_PAGE_SIZE);
1253 assert_eq!(exact.size(), MAX_PAGE_SIZE);
1254
1255 let over = PageRequest::new(1, MAX_PAGE_SIZE + 1);
1256 assert_eq!(over.size(), MAX_PAGE_SIZE);
1257
1258 let under = PageRequest::new(1, MAX_PAGE_SIZE - 1);
1259 assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1260 }
1261
1262 #[test]
1263 fn size_zero_falls_back_to_default() {
1264 let r = PageRequest::new(3, 0);
1265 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1266 }
1267
1268 #[test]
1269 fn offset_matches_page_and_size() {
1270 let r = PageRequest::new(3, 25);
1271 assert_eq!(r.offset(), 50);
1272 assert_eq!(r.limit(), 25);
1273 }
1274
1275 #[test]
1278 fn empty_page_has_one_total_page() {
1279 let req = PageRequest::new(5, 50);
1280 let page: Page<i32> = Page::empty(&req);
1281 assert_eq!(page.page, 5);
1282 assert_eq!(page.size, 50);
1283 assert_eq!(page.total_elements, 0);
1284 assert_eq!(page.total_pages, 1);
1285 assert!(!page.has_next);
1286 assert!(page.has_previous);
1287 assert!(page.content.is_empty());
1288 }
1289
1290 #[test]
1291 fn metadata_reflects_middle_page() {
1292 let req = PageRequest::new(3, 20);
1293 let page = Page::new(vec![1_i32; 20], 137, &req);
1294 assert_eq!(page.page, 3);
1295 assert_eq!(page.size, 20);
1296 assert_eq!(page.total_elements, 137);
1297 assert_eq!(page.total_pages, 7); assert!(page.has_next);
1299 assert!(page.has_previous);
1300 }
1301
1302 #[test]
1303 fn metadata_reflects_last_page() {
1304 let req = PageRequest::new(7, 20);
1305 let page = Page::new(vec![1_i32; 17], 137, &req);
1306 assert_eq!(page.total_pages, 7);
1307 assert!(!page.has_next);
1308 assert!(page.has_previous);
1309 }
1310
1311 #[test]
1312 fn negative_total_is_treated_as_zero() {
1313 let page: Page<i32> = Page::new(vec![], -1, &PageRequest::default());
1314 assert_eq!(page.total_elements, 0);
1315 assert_eq!(page.total_pages, 1);
1316 }
1317
1318 #[test]
1319 fn map_preserves_metadata() {
1320 let req = PageRequest::new(2, 10);
1321 let page = Page::new(vec![1_i32, 2, 3], 25, &req);
1322 let mapped = page.map(|n| n.to_string());
1323 assert_eq!(mapped.page, 2);
1324 assert_eq!(mapped.size, 10);
1325 assert_eq!(mapped.total_elements, 25);
1326 assert_eq!(mapped.total_pages, 3);
1327 assert!(mapped.has_next);
1328 assert!(mapped.has_previous);
1329 assert_eq!(mapped.content, vec!["1", "2", "3"]);
1330 }
1331
1332 #[test]
1335 fn page_serializes_to_expected_shape() {
1336 let req = PageRequest::new(2, 10);
1337 let page = Page::new(vec!["a", "b"], 25, &req);
1338 let json = serde_json::to_value(&page).unwrap();
1339 assert_eq!(json["page"], 2);
1340 assert_eq!(json["size"], 10);
1341 assert_eq!(json["total_elements"], 25);
1342 assert_eq!(json["total_pages"], 3);
1343 assert_eq!(json["has_next"], true);
1344 assert_eq!(json["has_previous"], true);
1345 assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1346 }
1347
1348 async fn echo(page: PageRequest) -> String {
1351 format!("{}:{}:{}", page.page(), page.size(), page.offset())
1352 }
1353
1354 async fn fetch(uri: &str) -> (StatusCode, String) {
1355 let app = Router::new().route("/items", get(echo));
1356 let res = app
1357 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1358 .await
1359 .unwrap();
1360 let status = res.status();
1361 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1362 .await
1363 .unwrap();
1364 (status, String::from_utf8(bytes.to_vec()).unwrap())
1365 }
1366
1367 #[tokio::test]
1368 async fn extractor_uses_defaults_when_query_missing() {
1369 let (status, body) = fetch("/items").await;
1370 assert_eq!(status, StatusCode::OK);
1371 assert_eq!(body, format!("1:{DEFAULT_PAGE_SIZE}:0"));
1372 }
1373
1374 #[tokio::test]
1375 async fn extractor_parses_page_and_size() {
1376 let (status, body) = fetch("/items?page=4&size=25").await;
1377 assert_eq!(status, StatusCode::OK);
1378 assert_eq!(body, "4:25:75");
1379 }
1380
1381 #[tokio::test]
1382 async fn extractor_clamps_size_over_max() {
1383 let (status, body) = fetch("/items?page=1&size=5000").await;
1384 assert_eq!(status, StatusCode::OK);
1385 assert_eq!(body, format!("1:{MAX_PAGE_SIZE}:0"));
1386 }
1387
1388 #[tokio::test]
1389 async fn extractor_coerces_page_zero_to_one() {
1390 let (status, body) = fetch("/items?page=0&size=10").await;
1391 assert_eq!(status, StatusCode::OK);
1392 assert_eq!(body, "1:10:0");
1393 }
1394
1395 #[tokio::test]
1402 async fn extractor_ignores_non_numeric_page() {
1403 let (status, body) = fetch("/items?page=abc&size=10").await;
1404 assert_eq!(status, StatusCode::OK);
1405 assert_eq!(body, "1:10:0");
1406 }
1407
1408 #[tokio::test]
1409 async fn extractor_ignores_empty_size() {
1410 let (status, body) = fetch("/items?page=2&size=").await;
1411 assert_eq!(status, StatusCode::OK);
1412 assert_eq!(body, format!("2:{DEFAULT_PAGE_SIZE}:{DEFAULT_PAGE_SIZE}"));
1413 }
1414
1415 #[tokio::test]
1416 async fn extractor_uses_last_value_on_duplicate_keys() {
1417 let (status, body) = fetch("/items?page=1&page=4&size=5").await;
1418 assert_eq!(status, StatusCode::OK);
1419 assert_eq!(body, "4:5:15");
1420 }
1421
1422 #[tokio::test]
1423 async fn extractor_ignores_unknown_keys() {
1424 let (status, body) = fetch("/items?sort=name&page=2&size=10").await;
1425 assert_eq!(status, StatusCode::OK);
1426 assert_eq!(body, "2:10:10");
1427 }
1428
1429 #[tokio::test]
1430 async fn extractor_handles_percent_encoded_values() {
1431 let (status, body) = fetch("/items?page=%32&size=10").await;
1433 assert_eq!(status, StatusCode::OK);
1434 assert_eq!(body, "2:10:10");
1435 }
1436
1437 #[tokio::test]
1438 async fn extractor_handles_negative_page_value() {
1439 let (status, body) = fetch("/items?page=-1&size=10").await;
1441 assert_eq!(status, StatusCode::OK);
1442 assert_eq!(body, "1:10:0");
1443 }
1444
1445 #[test]
1448 fn sort_dir_from_param_defaults_to_asc() {
1449 assert_eq!(SortDir::from_param("desc"), SortDir::Desc);
1450 assert_eq!(SortDir::from_param("DESC"), SortDir::Desc);
1451 assert_eq!(SortDir::from_param("asc"), SortDir::Asc);
1452 assert_eq!(SortDir::from_param(""), SortDir::Asc);
1454 assert_eq!(SortDir::from_param("descending"), SortDir::Asc);
1455 assert_eq!(SortDir::from_param("id;DROP TABLE"), SortDir::Asc);
1456 }
1457
1458 #[test]
1459 fn list_query_default_is_empty() {
1460 let q = ListQuery::default();
1461 assert!(q.sort().is_none());
1462 assert_eq!(q.direction(), SortDir::Asc);
1463 assert_eq!(q.filters().count(), 0);
1464 assert!(q.is_empty());
1465 }
1466
1467 #[test]
1468 fn list_query_parses_sort_dir_and_filters() {
1469 let q = parse_list_query("sort=title&dir=desc&filter[published]=true&filter[author]=ada");
1470 assert_eq!(q.sort(), Some("title"));
1471 assert_eq!(q.direction(), SortDir::Desc);
1472 let filters: Vec<(&str, &str)> = q.filters().collect();
1473 assert_eq!(filters, vec![("published", "true"), ("author", "ada")]);
1474 assert!(!q.is_empty());
1475 }
1476
1477 #[test]
1478 fn list_query_ignores_pagination_and_unknown_keys() {
1479 let q = parse_list_query("page=3&size=10&whatever=x&sort=name");
1481 assert_eq!(q.sort(), Some("name"));
1482 assert_eq!(q.filters().count(), 0);
1483 }
1484
1485 #[test]
1486 fn list_query_empty_sort_and_bad_dir_fall_back() {
1487 let q = parse_list_query("sort=&dir=sideways");
1489 assert!(q.sort().is_none());
1490 assert_eq!(q.direction(), SortDir::Asc);
1491 }
1492
1493 #[test]
1494 fn list_query_drops_empty_filter_column() {
1495 let q = parse_list_query("filter[]=orphan&filter[ok]=1");
1496 let filters: Vec<(&str, &str)> = q.filters().collect();
1497 assert_eq!(filters, vec![("ok", "1")]);
1498 }
1499
1500 #[test]
1501 fn list_query_last_sort_wins() {
1502 let q = parse_list_query("sort=a&sort=b");
1503 assert_eq!(q.sort(), Some("b"));
1504 }
1505
1506 #[test]
1507 fn list_query_carries_injection_payload_verbatim() {
1508 let q = parse_list_query("sort=id;DROP%20TABLE%20users");
1512 assert_eq!(q.sort(), Some("id;DROP TABLE users"));
1513 }
1514
1515 async fn list_echo(list: ListQuery) -> String {
1516 let filters: Vec<String> = list.filters().map(|(k, v)| format!("{k}={v}")).collect();
1517 format!(
1518 "{}:{}:{}",
1519 list.sort().unwrap_or("<none>"),
1520 list.direction().param_value(),
1521 filters.join(",")
1522 )
1523 }
1524
1525 async fn fetch_list(uri: &str) -> (StatusCode, String) {
1526 let app = Router::new().route("/items", get(list_echo));
1527 let res = app
1528 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1529 .await
1530 .unwrap();
1531 let status = res.status();
1532 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1533 .await
1534 .unwrap();
1535 (status, String::from_utf8(bytes.to_vec()).unwrap())
1536 }
1537
1538 #[tokio::test]
1539 async fn list_query_extractor_never_400s_on_garbage() {
1540 let (status, body) = fetch_list("/items?sort=id;DROP+TABLE&dir=%00&filter[x]=1").await;
1542 assert_eq!(status, StatusCode::OK);
1543 assert_eq!(body, "id;DROP TABLE:asc:x=1");
1544 }
1545
1546 #[tokio::test]
1547 async fn list_query_extractor_defaults_when_query_missing() {
1548 let (status, body) = fetch_list("/items").await;
1549 assert_eq!(status, StatusCode::OK);
1550 assert_eq!(body, "<none>:asc:");
1551 }
1552
1553 #[test]
1556 fn base64url_encode_known_vectors() {
1557 assert_eq!(base64url_encode(b""), "");
1559 assert_eq!(base64url_encode(b"f"), "Zg");
1560 assert_eq!(base64url_encode(b"fo"), "Zm8");
1561 assert_eq!(base64url_encode(b"foo"), "Zm9v");
1562 assert_eq!(base64url_encode(b"foob"), "Zm9vYg");
1563 assert_eq!(base64url_encode(b"fooba"), "Zm9vYmE");
1564 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1565 }
1566
1567 #[test]
1568 fn base64url_uses_url_safe_alphabet() {
1569 let encoded = base64url_encode(&[0xFB, 0xEF, 0xFF]);
1572 assert!(!encoded.contains('+'));
1573 assert!(!encoded.contains('/'));
1574 assert!(encoded.contains('-') || encoded.contains('_'));
1575 }
1576
1577 #[test]
1578 fn base64url_round_trip_arbitrary_bytes() {
1579 for len in 0_u8..=32 {
1580 let input: Vec<u8> = (0..len).map(|i| i.wrapping_mul(37)).collect();
1581 let encoded = base64url_encode(&input);
1582 let decoded = base64url_decode(&encoded).unwrap();
1583 assert_eq!(decoded, input, "round-trip failed at len {len}");
1584 }
1585 }
1586
1587 #[test]
1588 fn base64url_decode_rejects_invalid_chars() {
1589 assert_eq!(base64url_decode("!!!!"), None);
1590 assert_eq!(base64url_decode("AAAA="), None); assert_eq!(base64url_decode("AAA+"), None); }
1593
1594 #[test]
1595 fn base64url_decode_rejects_one_trailing_char() {
1596 assert_eq!(base64url_decode("A"), None);
1598 assert_eq!(base64url_decode("ZmA"), Some(vec![0x66, 0x60]));
1599 }
1600
1601 #[derive(Serialize, Deserialize, PartialEq, Debug)]
1604 struct PostKey {
1605 created_at: String,
1606 id: i64,
1607 }
1608
1609 #[test]
1610 fn cursor_round_trip_preserves_payload() {
1611 let key = PostKey {
1612 created_at: "2026-04-27T12:00:00Z".to_string(),
1613 id: 12_345,
1614 };
1615 let token = Cursor::encode(&key).unwrap();
1616 let decoded: PostKey = Cursor::decode(&token).unwrap();
1617 assert_eq!(decoded, key);
1618 }
1619
1620 #[test]
1621 fn cursor_token_is_url_safe() {
1622 let key = PostKey {
1625 created_at: "2026-04-27T12:00:00Z".to_string(),
1626 id: 1,
1627 };
1628 let token = Cursor::encode(&key).unwrap();
1629 assert!(
1631 token
1632 .bytes()
1633 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1634 );
1635 }
1636
1637 #[test]
1638 fn cursor_decode_returns_none_for_garbage() {
1639 let decoded: Option<PostKey> = Cursor::decode("!!!not a token!!!");
1641 assert!(decoded.is_none());
1642 }
1643
1644 #[test]
1645 fn cursor_decode_returns_none_for_wrong_schema() {
1646 let other = serde_json::json!({"unrelated": "value"});
1648 let token = Cursor::encode(&other).unwrap();
1649 let decoded: Option<PostKey> = Cursor::decode(&token);
1650 assert!(decoded.is_none());
1651 }
1652
1653 #[test]
1656 fn cursor_request_defaults_when_empty() {
1657 let r = CursorRequest::default();
1658 assert!(r.cursor().is_none());
1659 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1660 assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1661 assert_eq!(r.fetch_limit(), i64::from(DEFAULT_PAGE_SIZE) + 1);
1662 }
1663
1664 #[test]
1665 fn cursor_request_clamps_size_to_max() {
1666 let r = CursorRequest::new(None, 9_999);
1667 assert_eq!(r.size(), MAX_PAGE_SIZE);
1668 assert_eq!(r.fetch_limit(), i64::from(MAX_PAGE_SIZE) + 1);
1669
1670 let exact = CursorRequest::new(None, MAX_PAGE_SIZE);
1671 assert_eq!(exact.size(), MAX_PAGE_SIZE);
1672
1673 let over = CursorRequest::new(None, MAX_PAGE_SIZE + 1);
1674 assert_eq!(over.size(), MAX_PAGE_SIZE);
1675
1676 let under = CursorRequest::new(None, MAX_PAGE_SIZE - 1);
1677 assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1678 }
1679
1680 #[test]
1681 fn cursor_request_zero_size_falls_back_to_default() {
1682 let r = CursorRequest::new(None, 0);
1683 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1684 }
1685
1686 #[test]
1687 fn cursor_request_decode_helper_returns_none_when_missing() {
1688 let r = CursorRequest::default();
1689 let decoded: Option<PostKey> = r.decode();
1690 assert!(decoded.is_none());
1691 }
1692
1693 #[test]
1694 fn cursor_request_decode_helper_round_trips() {
1695 let key = PostKey {
1696 created_at: "2026-04-27T00:00:00Z".to_string(),
1697 id: 7,
1698 };
1699 let token = Cursor::encode(&key).unwrap();
1700 let r = CursorRequest::new(Some(token), 10);
1701 let decoded: PostKey = r.decode().unwrap();
1702 assert_eq!(decoded, key);
1703 }
1704
1705 #[test]
1708 fn cursor_page_signals_no_next_when_under_limit() {
1709 let req = CursorRequest::new(None, 5);
1710 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1712 assert_eq!(page.content, vec![1, 2, 3]);
1713 assert!(!page.has_next);
1714 assert!(page.next_cursor.is_none());
1715 assert_eq!(page.size, 5);
1716 }
1717
1718 #[test]
1719 fn cursor_page_signals_no_next_at_exact_limit() {
1720 let req = CursorRequest::new(None, 3);
1721 let items = vec![1_i32, 2, 3];
1723 let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1724 assert_eq!(page.content.len(), 3);
1725 assert!(!page.has_next);
1726 assert!(page.next_cursor.is_none());
1727 }
1728
1729 #[test]
1730 fn cursor_page_truncates_overflow_and_emits_next_cursor() {
1731 let req = CursorRequest::new(None, 3);
1732 let items = vec![1_i32, 2, 3, 4];
1734 let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1735 assert_eq!(page.content, vec![1, 2, 3]);
1737 assert!(page.has_next);
1738 let token = page.next_cursor.as_ref().expect("next cursor present");
1739 let decoded: serde_json::Value = Cursor::decode(token).unwrap();
1744 assert_eq!(decoded, serde_json::json!({"id": 3}));
1745 }
1746
1747 #[test]
1748 fn cursor_page_from_overfetched_handles_encoding_failure() {
1749 let req = CursorRequest::new(None, 2);
1750 let items = vec![1_i32, 2, 3];
1751 let page = CursorPage::from_overfetched_inner(
1752 items,
1753 &req,
1754 |&n| n,
1755 |_| None::<String>, );
1757
1758 assert_eq!(page.content, vec![1, 2]);
1759 assert_eq!(page.size, 2);
1760 assert!(page.next_cursor.is_none());
1761 assert!(!page.has_next);
1762 }
1763
1764 #[test]
1765 fn cursor_page_empty_helper() {
1766 let req = CursorRequest::new(None, 10);
1767 let page: CursorPage<i32> = CursorPage::empty(&req);
1768 assert!(page.content.is_empty());
1769 assert_eq!(page.size, 10);
1770 assert!(!page.has_next);
1771 assert!(page.next_cursor.is_none());
1772
1773 let req_diff_size = CursorRequest::new(None, 5);
1774 let page_diff_size: CursorPage<i32> = CursorPage::empty(&req_diff_size);
1775 assert_eq!(page_diff_size.size, 5);
1776 }
1777
1778 #[test]
1779 fn cursor_page_map_preserves_metadata() {
1780 let req = CursorRequest::new(None, 2);
1781 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1783
1784 let original_cursor = page.next_cursor.clone();
1785
1786 let mapped = page.map(|n| n.to_string());
1787 assert_eq!(mapped.content, vec!["1", "2"]);
1788 assert!(mapped.has_next);
1789 assert_eq!(mapped.next_cursor, original_cursor);
1790 assert_eq!(mapped.size, 2);
1791 }
1792
1793 #[test]
1794 fn cursor_page_serializes_to_expected_shape() {
1795 let req = CursorRequest::new(None, 2);
1796 let items = vec!["a", "b", "c"];
1797 let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1798 let json = serde_json::to_value(&page).unwrap();
1799 assert_eq!(json["size"], 2);
1800 assert_eq!(json["has_next"], true);
1801 assert!(json["next_cursor"].is_string());
1802 assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1803 }
1804
1805 #[test]
1806 fn cursor_page_last_page_serializes_null_cursor() {
1807 let req = CursorRequest::new(None, 5);
1808 let items = vec!["only"];
1809 let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1810 let json = serde_json::to_value(&page).unwrap();
1811 assert_eq!(json["has_next"], false);
1812 assert!(json["next_cursor"].is_null());
1813 }
1814
1815 async fn cursor_echo(req: CursorRequest) -> String {
1818 format!(
1819 "{}|{}|{}",
1820 req.cursor().unwrap_or("-"),
1821 req.size(),
1822 req.fetch_limit(),
1823 )
1824 }
1825
1826 async fn fetch_cursor(uri: &str) -> (StatusCode, String) {
1827 let app = Router::new().route("/feed", get(cursor_echo));
1828 let res = app
1829 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1830 .await
1831 .unwrap();
1832 let status = res.status();
1833 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1834 .await
1835 .unwrap();
1836 (status, String::from_utf8(bytes.to_vec()).unwrap())
1837 }
1838
1839 #[tokio::test]
1840 async fn cursor_extractor_uses_defaults_when_query_missing() {
1841 let (status, body) = fetch_cursor("/feed").await;
1842 assert_eq!(status, StatusCode::OK);
1843 assert_eq!(
1844 body,
1845 format!("-|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1846 );
1847 }
1848
1849 #[tokio::test]
1850 async fn cursor_extractor_parses_cursor_and_size() {
1851 let (status, body) = fetch_cursor("/feed?cursor=abc123&size=5").await;
1852 assert_eq!(status, StatusCode::OK);
1853 assert_eq!(body, "abc123|5|6");
1854 }
1855
1856 #[tokio::test]
1857 async fn cursor_extractor_clamps_size_over_max() {
1858 let (status, body) = fetch_cursor("/feed?cursor=t&size=9999").await;
1859 assert_eq!(status, StatusCode::OK);
1860 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1861
1862 let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={MAX_PAGE_SIZE}")).await;
1864 assert_eq!(status, StatusCode::OK);
1865 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1866
1867 let size = MAX_PAGE_SIZE + 1;
1869 let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={size}")).await;
1870 assert_eq!(status, StatusCode::OK);
1871 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1872 }
1873
1874 #[tokio::test]
1875 async fn cursor_extractor_ignores_empty_cursor() {
1876 let (status, body) = fetch_cursor("/feed?cursor=&size=10").await;
1878 assert_eq!(status, StatusCode::OK);
1879 assert_eq!(body, "-|10|11");
1880 }
1881
1882 #[tokio::test]
1883 async fn cursor_extractor_does_not_400_on_malformed_size() {
1884 let (status, body) = fetch_cursor("/feed?cursor=t&size=abc").await;
1885 assert_eq!(status, StatusCode::OK);
1886 assert_eq!(
1887 body,
1888 format!("t|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1889 );
1890 }
1891
1892 #[tokio::test]
1893 async fn cursor_extractor_handles_percent_encoded_token() {
1894 let (status, body) = fetch_cursor("/feed?cursor=ab%2Dcd&size=2").await;
1897 assert_eq!(status, StatusCode::OK);
1898 assert_eq!(body, "ab-cd|2|3");
1899 }
1900
1901 #[test]
1909 fn concurrent_inserts_do_not_cause_duplicates() {
1910 #[derive(Clone, Debug, PartialEq, Eq)]
1913 struct Row {
1914 id: i64,
1915 created_at: i64,
1916 }
1917 #[derive(Serialize, Deserialize)]
1918 struct Key {
1919 created_at: i64,
1920 id: i64,
1921 }
1922
1923 let mut table: Vec<Row> = (1..=5)
1924 .map(|id| Row {
1925 id,
1926 created_at: 1_000 - id, })
1928 .collect();
1929 table.sort_by_key(|r| std::cmp::Reverse((r.created_at, r.id)));
1931
1932 let req1 = CursorRequest::new(None, 2);
1934 let fetch1 = usize::try_from(req1.fetch_limit()).unwrap();
1935 let fetched1: Vec<Row> = table.iter().take(fetch1).cloned().collect();
1936 let page1 = CursorPage::from_overfetched(fetched1, &req1, |r| Key {
1937 created_at: r.created_at,
1938 id: r.id,
1939 });
1940 let cursor1 = page1.next_cursor.clone().expect("page 1 has next");
1941 assert_eq!(page1.content.len(), 2);
1942
1943 table.insert(
1948 0,
1949 Row {
1950 id: 99,
1951 created_at: 9_999,
1952 },
1953 );
1954
1955 let req2 = CursorRequest::new(Some(cursor1), 2);
1957 let key: Key = req2.decode().unwrap();
1958 let fetch2 = usize::try_from(req2.fetch_limit()).unwrap();
1959
1960 let fetched2: Vec<Row> = table
1964 .iter()
1965 .filter(|r| {
1966 r.created_at < key.created_at || (r.created_at == key.created_at && r.id < key.id)
1967 })
1968 .take(fetch2)
1969 .cloned()
1970 .collect();
1971 let page2 = CursorPage::from_overfetched(fetched2, &req2, |r| Key {
1972 created_at: r.created_at,
1973 id: r.id,
1974 });
1975
1976 let mut all: Vec<Row> = page1.content;
1980 all.extend(page2.content);
1981 let mut ids: Vec<i64> = all.iter().map(|r| r.id).collect();
1982 let original_len = ids.len();
1983 ids.sort_unstable();
1984 ids.dedup();
1985 assert_eq!(ids.len(), original_len, "no duplicates across pages");
1986 assert!(
1987 !all.iter().any(|r| r.id == 99),
1988 "concurrently-inserted row not duplicated"
1989 );
1990 }
1991
1992 const TEST_KEY: &[u8] = b"test-signing-key-do-not-use-in-prod";
1995
1996 #[derive(Serialize, Deserialize, PartialEq, Debug)]
1997 struct ScopedCursor {
1998 tenant_id: i64,
1999 cursor_id: i64,
2000 }
2001
2002 #[test]
2003 fn signed_cursor_round_trip() {
2004 let payload = ScopedCursor {
2005 tenant_id: 42,
2006 cursor_id: 7,
2007 };
2008 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2009 assert!(token.contains('.'));
2011 let decoded: ScopedCursor = Cursor::decode_signed(&token, TEST_KEY).unwrap();
2012 assert_eq!(decoded, payload);
2013 }
2014
2015 #[test]
2016 fn signed_cursor_rejects_tampered_payload() {
2017 let payload = ScopedCursor {
2018 tenant_id: 42,
2019 cursor_id: 7,
2020 };
2021 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2022 let forged_payload = ScopedCursor {
2025 tenant_id: 99,
2026 cursor_id: 7,
2027 };
2028 let forged_b64 = base64url_encode(&serde_json::to_vec(&forged_payload).unwrap());
2029 let (_, sig_b64) = token.split_once('.').unwrap();
2030 let forged_token = format!("{forged_b64}.{sig_b64}");
2031 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&forged_token, TEST_KEY);
2032 assert!(decoded.is_none(), "tampered cursor must not verify");
2033 }
2034
2035 #[test]
2036 fn signed_cursor_rejects_wrong_key() {
2037 let payload = ScopedCursor {
2038 tenant_id: 42,
2039 cursor_id: 7,
2040 };
2041 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2042 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&token, b"different-key");
2043 assert!(decoded.is_none());
2044 }
2045
2046 #[test]
2047 fn signed_cursor_rejects_unsigned_token() {
2048 let payload = ScopedCursor {
2051 tenant_id: 42,
2052 cursor_id: 7,
2053 };
2054 let unsigned = Cursor::encode(&payload).unwrap();
2055 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&unsigned, TEST_KEY);
2056 assert!(
2057 decoded.is_none(),
2058 "unsigned token must not pass signed verification"
2059 );
2060 }
2061
2062 #[test]
2063 fn signed_cursor_rejects_missing_signature_segment() {
2064 let decoded: Option<ScopedCursor> = Cursor::decode_signed("just-some-bytes", TEST_KEY);
2066 assert!(decoded.is_none());
2067 }
2068
2069 #[test]
2070 fn signed_cursor_rejects_garbage() {
2071 let decoded: Option<ScopedCursor> = Cursor::decode_signed("!!!.!!!", TEST_KEY);
2072 assert!(decoded.is_none());
2073 }
2074
2075 #[test]
2076 fn cursor_request_decode_signed_returns_none_when_missing() {
2077 let r = CursorRequest::default();
2078 let decoded: Option<ScopedCursor> = r.decode_signed(TEST_KEY);
2079 assert!(decoded.is_none());
2080 }
2081
2082 #[test]
2083 fn cursor_request_decode_signed_round_trips() {
2084 let payload = ScopedCursor {
2085 tenant_id: 42,
2086 cursor_id: 7,
2087 };
2088 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2089 let r = CursorRequest::new(Some(token), 10);
2090 let decoded: ScopedCursor = r.decode_signed(TEST_KEY).unwrap();
2091 assert_eq!(decoded, payload);
2092 }
2093
2094 #[test]
2095 fn cursor_page_from_overfetched_signed_emits_signed_token() {
2096 let req = CursorRequest::new(None, 2);
2097 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched_signed(items, &req, TEST_KEY, |&n| ScopedCursor {
2099 tenant_id: 42,
2100 cursor_id: i64::from(n),
2101 });
2102 assert!(page.has_next);
2103 let token = page.next_cursor.as_ref().unwrap();
2104 assert!(token.contains('.'), "signed token format is payload.sig");
2105 let key: ScopedCursor = Cursor::decode_signed(token, TEST_KEY).unwrap();
2107 assert_eq!(key.cursor_id, 2); let mishandled: Option<ScopedCursor> = Cursor::decode(token);
2111 assert!(mishandled.is_none());
2112 }
2113
2114 #[test]
2115 fn signed_cursor_signature_is_constant_time_compared() {
2116 let p = ScopedCursor {
2120 tenant_id: 1,
2121 cursor_id: 1,
2122 };
2123 let a = Cursor::encode_signed(&p, b"k1").unwrap();
2124 let b = Cursor::encode_signed(&p, b"k1").unwrap();
2125 let c = Cursor::encode_signed(&p, b"k2").unwrap();
2126 assert_eq!(a, b);
2127 assert_ne!(a, c);
2128 }
2129
2130 #[tokio::test]
2131 async fn page_request_extractor_defaults_on_missing_uri_query() {
2132 use axum::extract::FromRequestParts;
2133 use axum::http::Request;
2134 let req = Request::builder().uri("/").body(()).unwrap();
2135 let (mut parts, ()) = req.into_parts();
2136
2137 let extracted = PageRequest::from_request_parts(&mut parts, &())
2138 .await
2139 .unwrap();
2140
2141 assert_eq!(extracted.page(), 1);
2142 assert_eq!(extracted.size(), 20); }
2144
2145 #[tokio::test]
2146 async fn cursor_extractor_defaults_on_missing_uri_query() {
2147 use axum::extract::FromRequestParts;
2148 use axum::http::Request;
2149 let req = Request::builder().uri("/").body(()).unwrap();
2150 let (mut parts, ()) = req.into_parts();
2151
2152 let extracted = CursorRequest::from_request_parts(&mut parts, &())
2153 .await
2154 .unwrap();
2155
2156 assert_eq!(extracted.size(), 20); assert!(extracted.cursor.is_none());
2158 }
2159
2160 #[test]
2161 fn base64url_encode_pad_branch() {
2162 let encoded = base64url_encode(b"ab");
2164 assert_eq!(encoded, "YWI");
2165 }
2166
2167 #[test]
2168 fn base64url_decode_pad_branch() {
2169 let decoded = base64url_decode("YWI").unwrap();
2172 assert_eq!(decoded, b"ab");
2173 }
2174
2175 #[test]
2176 fn cursor_encode_fails_gracefully_on_serialization_error() {
2177 use serde::Serialize;
2178
2179 struct FailToSerialize;
2180
2181 impl Serialize for FailToSerialize {
2182 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2183 where
2184 S: serde::Serializer,
2185 {
2186 Err(serde::ser::Error::custom("forced failure"))
2187 }
2188 }
2189
2190 let res = Cursor::encode(&FailToSerialize);
2191 assert!(res.is_err());
2192
2193 let res_signed = Cursor::encode_signed(&FailToSerialize, b"key");
2194 assert!(res_signed.is_err());
2195 }
2196
2197 #[tokio::test]
2200 async fn page_into_response_emits_envelope_and_link_headers() {
2201 async fn handler() -> Page<u32> {
2202 Page::new((21..=40).collect(), 137, &PageRequest::new(2, 20))
2204 }
2205 let app = Router::new().route("/items", get(handler));
2206 let res = app
2207 .oneshot(
2208 Request::builder()
2209 .uri("/items")
2210 .body(Body::empty())
2211 .unwrap(),
2212 )
2213 .await
2214 .unwrap();
2215
2216 assert_eq!(res.status(), StatusCode::OK);
2217 let link = res
2218 .headers()
2219 .get(header::LINK)
2220 .expect("Link header must be present")
2221 .to_str()
2222 .unwrap()
2223 .to_owned();
2224 assert!(link.contains("<?page=1&size=20>; rel=\"first\""), "{link}");
2225 assert!(
2226 link.contains("<?page=1&size=20>; rel=\"prev\""),
2227 "prev must point to page 1: {link}"
2228 );
2229 assert!(link.contains("<?page=3&size=20>; rel=\"next\""), "{link}");
2230 assert!(link.contains("<?page=7&size=20>; rel=\"last\""), "{link}");
2231
2232 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2233 .await
2234 .unwrap();
2235 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2236 assert_eq!(json["page"], 2);
2237 assert_eq!(json["size"], 20);
2238 assert_eq!(json["total_elements"], 137);
2239 assert_eq!(json["total_pages"], 7);
2240 assert_eq!(json["content"].as_array().unwrap().len(), 20);
2241 }
2242
2243 #[tokio::test]
2244 async fn first_page_has_no_prev_link() {
2245 async fn handler() -> Page<u32> {
2246 Page::new((1..=20).collect(), 40, &PageRequest::new(1, 20))
2247 }
2248 let app = Router::new().route("/items", get(handler));
2249 let res = app
2250 .oneshot(
2251 Request::builder()
2252 .uri("/items")
2253 .body(Body::empty())
2254 .unwrap(),
2255 )
2256 .await
2257 .unwrap();
2258 let link = res
2259 .headers()
2260 .get(header::LINK)
2261 .unwrap()
2262 .to_str()
2263 .unwrap()
2264 .to_owned();
2265 assert!(
2266 !link.contains("rel=\"prev\""),
2267 "page 1 must not advertise prev: {link}"
2268 );
2269 assert!(link.contains("rel=\"next\""), "{link}");
2270 assert!(link.contains("rel=\"first\""), "{link}");
2271 assert!(link.contains("rel=\"last\""), "{link}");
2272 }
2273
2274 #[test]
2275 fn empty_page_last_link_clamps_to_one() {
2276 let link = page_link_header_value(1, 20, 0, false);
2279 assert!(
2280 link.contains("<?page=1&size=20>; rel=\"last\""),
2281 "last link must clamp to page 1 for an empty collection: {link}"
2282 );
2283 assert!(
2284 !link.contains("page=0"),
2285 "no link may reference the invalid page 0: {link}"
2286 );
2287 }
2288
2289 #[tokio::test]
2290 async fn cursor_page_emits_next_link_only() {
2291 async fn handler() -> CursorPage<u32> {
2292 CursorPage::from_overfetched(vec![1, 2, 3], &CursorRequest::new(None, 2), |&n| n)
2294 }
2295 let app = Router::new().route("/feed", get(handler));
2296 let res = app
2297 .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2298 .await
2299 .unwrap();
2300 let link = res
2301 .headers()
2302 .get(header::LINK)
2303 .expect("cursor page with a next page must emit a Link")
2304 .to_str()
2305 .unwrap()
2306 .to_owned();
2307 assert!(link.contains("rel=\"next\""), "{link}");
2308 assert!(
2309 link.contains("cursor="),
2310 "next link must carry the cursor token: {link}"
2311 );
2312 assert!(
2313 !link.contains("rel=\"prev\""),
2314 "keyset pagination has no prev: {link}"
2315 );
2316 }
2317
2318 #[tokio::test]
2319 async fn cursor_page_last_page_has_no_link() {
2320 async fn handler() -> CursorPage<u32> {
2321 CursorPage::from_overfetched(vec![1, 2], &CursorRequest::new(None, 5), |&n| n)
2323 }
2324 let app = Router::new().route("/feed", get(handler));
2325 let res = app
2326 .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2327 .await
2328 .unwrap();
2329 assert!(
2330 res.headers().get(header::LINK).is_none(),
2331 "last cursor page must not advertise a next link"
2332 );
2333 }
2334
2335 #[test]
2336 fn paginate_in_memory_windows_and_counts() {
2337 let all: Vec<u32> = (1..=25).collect();
2338 let page = Page::paginate_in_memory(all, &PageRequest::new(2, 10));
2339 assert_eq!(page.page, 2);
2340 assert_eq!(page.size, 10);
2341 assert_eq!(page.total_elements, 25);
2342 assert_eq!(page.total_pages, 3);
2343 assert_eq!(page.content, (11..=20).collect::<Vec<_>>());
2344 assert!(page.has_next);
2345 assert!(page.has_previous);
2346 }
2347}