use http::Uri;
use percent_encoding::percent_decode_str;
use jsonapi_core::{Links, PageStrategy, PaginationLinks};
#[must_use]
pub fn pagination_links(uri: &Uri, strategy: PageStrategy, total: Option<u64>) -> Links {
build_links(uri.path(), uri, strategy, total)
}
#[must_use]
pub fn pagination_links_with_base(
base: &str,
uri: &Uri,
strategy: PageStrategy,
total: Option<u64>,
) -> Links {
let base = base.strip_suffix('/').unwrap_or(base);
let full_base = format!("{base}{}", uri.path());
build_links(&full_base, uri, strategy, total)
}
fn build_links(base_path: &str, uri: &Uri, strategy: PageStrategy, total: Option<u64>) -> Links {
let preserved = preserved_params(uri.query().unwrap_or(""));
let preserved_refs: Vec<(&str, &str)> = preserved
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut builder = PaginationLinks::new(base_path, strategy).preserve(&preserved_refs);
if let Some(total) = total {
builder = builder.total(total);
}
builder.build()
}
fn preserved_params(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter(|pair| !pair.is_empty())
.filter_map(|pair| {
let (raw_key, raw_value) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
let key = decode(raw_key);
if key.starts_with("page[") {
return None;
}
Some((key, decode(raw_value)))
})
.collect()
}
fn decode(raw: &str) -> String {
percent_decode_str(raw).decode_utf8_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn link_str(links: &Links, rel: &str) -> String {
match links.get(rel).unwrap() {
jsonapi_core::Link::String(s) => s.clone(),
_ => panic!("expected string link for {rel}"),
}
}
#[test]
fn preserves_non_page_params_and_drops_page_params() {
let uri: Uri = "/articles?sort=-created&page%5Bnumber%5D=2&page%5Bsize%5D=10&filter%5Bstatus%5D=published"
.parse()
.unwrap();
let links = pagination_links(
&uri,
PageStrategy::PageNumber {
number: 2,
size: 10,
},
Some(35),
);
let next = link_str(&links, "next");
assert!(next.starts_with("/articles?"), "{next}");
assert!(next.contains("sort=-created"), "{next}");
assert!(next.contains("filter[status]=published"), "{next}");
assert!(next.contains("page[number]=3"), "{next}");
assert!(next.contains("page[size]=10"), "{next}");
assert!(!next.contains("page[number]=2"), "{next}");
}
#[test]
fn offset_strategy_over_request_uri() {
let uri: Uri = "/articles?page[offset]=0&page[limit]=10".parse().unwrap();
let links = pagination_links(
&uri,
PageStrategy::Offset {
offset: 0,
limit: 10,
},
Some(25),
);
assert!(!links.contains("prev"), "first page omits prev");
assert!(link_str(&links, "next").contains("page[offset]=10"));
assert!(link_str(&links, "last").contains("page[offset]=20"));
}
#[test]
fn with_base_prefixes_configured_host_on_every_link() {
let uri: Uri = "/articles?sort=-created&page[number]=2&page[size]=10"
.parse()
.unwrap();
let links = pagination_links_with_base(
"https://api.example.com",
&uri,
PageStrategy::PageNumber {
number: 2,
size: 10,
},
Some(35),
);
for rel in ["first", "prev", "next", "self"] {
let link = link_str(&links, rel);
assert!(
link.starts_with("https://api.example.com/articles?"),
"{rel}: {link}"
);
}
let next = link_str(&links, "next");
assert!(next.contains("sort=-created"), "{next}");
assert!(next.contains("page[number]=3"), "{next}");
}
#[test]
fn with_base_trims_trailing_slash() {
let uri: Uri = "/articles".parse().unwrap();
let links = pagination_links_with_base(
"https://api.example.com/",
&uri,
PageStrategy::Offset {
offset: 0,
limit: 5,
},
None,
);
assert!(
link_str(&links, "self").starts_with("https://api.example.com/articles?"),
"no double slash before path"
);
}
#[test]
fn no_query_string_still_builds_links() {
let uri: Uri = "/articles".parse().unwrap();
let links = pagination_links(
&uri,
PageStrategy::Offset {
offset: 0,
limit: 5,
},
None,
);
assert!(link_str(&links, "self").starts_with("/articles?page[offset]=0"));
}
}