use super::error::MassiveError;
use crate::exchange::http::truncate_str;
use std::collections::HashSet;
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
pub(super) const MAX_PAGES: usize = 10_000;
pub(super) const MAX_URL_BYTES: usize = 8 * 1024;
const URL_PREFIX_BYTES: usize = 200;
#[derive(Debug, Default)]
pub(super) struct PaginationGuard {
pages: usize,
visited: HashSet<u64>,
hasher: RandomState,
}
impl PaginationGuard {
pub(super) fn observe(&mut self, url: &str) -> Result<(), MassiveError> {
self.pages += 1;
if self.pages > MAX_PAGES {
return Err(MassiveError::PaginationLimitExceeded {
pages: self.pages,
limit: MAX_PAGES,
});
}
if url.len() > MAX_URL_BYTES {
return Err(MassiveError::PaginationUrlTooLong {
len: url.len(),
limit: MAX_URL_BYTES,
prefix: truncate_str(url, URL_PREFIX_BYTES),
});
}
if !self.visited.insert(self.hasher.hash_one(url)) {
return Err(MassiveError::CyclicPagination {
url: url.to_owned(),
});
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod tests {
use super::*;
#[test]
fn distinct_urls_under_the_cap_are_accepted() {
let mut guard = PaginationGuard::default();
for i in 0..MAX_PAGES {
assert!(
guard
.observe(&format!("https://api.massive.com/page/{i}"))
.is_ok(),
"page {i} within the cap should be accepted"
);
}
}
#[test]
fn exceeding_the_page_cap_is_a_terminal_error() {
let mut guard = PaginationGuard::default();
for i in 0..MAX_PAGES {
guard
.observe(&format!("https://api.massive.com/page/{i}"))
.expect("within cap");
}
let err = guard
.observe("https://api.massive.com/page/over")
.expect_err("page MAX_PAGES + 1 must be rejected");
assert_eq!(
err,
MassiveError::PaginationLimitExceeded {
pages: MAX_PAGES + 1,
limit: MAX_PAGES,
}
);
}
#[test]
fn revisiting_a_url_is_detected_as_a_cycle() {
let mut guard = PaginationGuard::default();
let url = "https://api.massive.com/v2/aggs/ticker/X:BTCUSD/range/1/minute/0/1?cursor=abc";
guard.observe(url).expect("first visit is fine");
let err = guard.observe(url).expect_err("second visit is a cycle");
assert_eq!(
err,
MassiveError::CyclicPagination {
url: url.to_owned()
}
);
}
#[test]
fn cycle_detection_is_url_exact() {
let mut guard = PaginationGuard::default();
guard
.observe("https://api.massive.com/x?cursor=a")
.expect("page 1");
guard
.observe("https://api.massive.com/x?cursor=b")
.expect("page 2 is a different URL");
}
#[test]
fn long_urls_sharing_a_prefix_are_not_a_false_cycle() {
let mut guard = PaginationGuard::default();
let base = format!("https://api.massive.com/x?cursor={}", "a".repeat(2048));
guard.observe(&format!("{base}1")).expect("page 1");
guard
.observe(&format!("{base}2"))
.expect("page 2 differs only in its last byte but is a different URL");
}
#[test]
fn a_url_at_the_byte_cap_is_accepted() {
let mut guard = PaginationGuard::default();
let url = "a".repeat(MAX_URL_BYTES);
guard.observe(&url).expect("exactly at the cap is allowed");
}
#[test]
fn a_url_over_the_byte_cap_is_rejected_with_a_bounded_prefix() {
let mut guard = PaginationGuard::default();
let url = format!("https://api.massive.com/{}", "a".repeat(MAX_URL_BYTES));
let err = guard
.observe(&url)
.expect_err("past the cap must be rejected");
match err {
MassiveError::PaginationUrlTooLong { len, limit, prefix } => {
assert_eq!(len, url.len());
assert_eq!(limit, MAX_URL_BYTES);
assert_eq!(prefix.len(), URL_PREFIX_BYTES);
assert!(prefix.starts_with("https://api.massive.com/"));
}
other => panic!("expected PaginationUrlTooLong, got {other:?}"),
}
}
#[test]
fn an_oversized_url_is_rejected_before_being_recorded() {
let mut guard = PaginationGuard::default();
let url = "a".repeat(MAX_URL_BYTES + 1);
guard.observe(&url).expect_err("rejected");
assert!(
guard.visited.is_empty(),
"a rejected URL must leave no trace in the visited set"
);
}
}