From 6deabf1ec57e6f6290024c36249950c33c4ba127 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Wed, 29 Jul 2026 13:02:26 +0200 Subject: [PATCH 1/2] feat: support transaction cursor pagination --- CHANGELOG.md | 4 ++ src/api/endpoints/accounts.rs | 9 ++-- src/api/endpoints/addresses.rs | 9 ++-- src/api/endpoints/assets.rs | 12 +++-- src/api/mod.rs | 83 +++++++++++++++++++++++++++++++- src/lib.rs | 1 + src/pagination.rs | 86 ++++++++++++++++++++++++++++++++++ src/url.rs | 85 ++++++++++++++++++++++++++++++++- 8 files changed, 277 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528aae7..fd98e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Inclusive `from`/`to` block cursor support for account, address, and asset transaction endpoints + ## 1.2.4 - 2026-05-28 ### Added diff --git a/src/api/endpoints/accounts.rs b/src/api/endpoints/accounts.rs index 0772401..14f261d 100644 --- a/src/api/endpoints/accounts.rs +++ b/src/api/endpoints/accounts.rs @@ -139,12 +139,15 @@ impl BlockfrostAPI { } /// Transactions of a specific account. + /// + /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to + /// restrict results to an inclusive block range. pub async fn accounts_transactions( - &self, stake_address: &str, pagination: Pagination, + &self, stake_address: &str, pagination: impl Into, ) -> BlockfrostResult> { - self.call_paged_endpoint( + self.call_cursor_paged_endpoint( format!("/accounts/{stake_address}/transactions").as_str(), - pagination, + pagination.into(), ) .await } diff --git a/src/api/endpoints/addresses.rs b/src/api/endpoints/addresses.rs index 9de1c33..19f506c 100644 --- a/src/api/endpoints/addresses.rs +++ b/src/api/endpoints/addresses.rs @@ -48,12 +48,15 @@ impl BlockfrostAPI { } /// Return the transactions for a specific address. + /// + /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to + /// restrict results to an inclusive block range. pub async fn addresses_transactions( - &self, address: &str, pagination: Pagination, + &self, address: &str, pagination: impl Into, ) -> BlockfrostResult> { - self.call_paged_endpoint( + self.call_cursor_paged_endpoint( format!("/addresses/{address}/transactions").as_str(), - pagination, + pagination.into(), ) .await } diff --git a/src/api/endpoints/assets.rs b/src/api/endpoints/assets.rs index b2d6148..54c03dc 100644 --- a/src/api/endpoints/assets.rs +++ b/src/api/endpoints/assets.rs @@ -26,11 +26,17 @@ impl BlockfrostAPI { } /// Return the transactions for a specific asset. + /// + /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to + /// restrict results to an inclusive block range. pub async fn assets_transactions( - &self, asset: &str, pagination: Pagination, + &self, asset: &str, pagination: impl Into, ) -> BlockfrostResult> { - self.call_paged_endpoint(format!("/assets/{asset}/transactions").as_str(), pagination) - .await + self.call_cursor_paged_endpoint( + format!("/assets/{asset}/transactions").as_str(), + pagination.into(), + ) + .await } /// Return the addresses holding a specific asset. diff --git a/src/api/mod.rs b/src/api/mod.rs index e3ab86c..fe3dd74 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ pub(super) mod endpoints; use crate::{ - pagination::Pagination, + pagination::{CursorPagination, Pagination}, request::{fetch_all_pages, send_get_request}, url::Url, utils::build_header_map, @@ -79,4 +79,85 @@ impl BlockfrostAPI { send_get_request(&self.client, url, self.settings.retry_settings).await } } + + async fn call_cursor_paged_endpoint( + &self, url_endpoint: &str, pagination: CursorPagination, + ) -> Result, BlockfrostError> + where + T: for<'de> serde::Deserialize<'de> + serde::de::DeserializeOwned, + { + let url = + Url::from_cursor_paginated_endpoint(self.base_url.as_str(), url_endpoint, &pagination)?; + + if pagination.pagination.fetch_all { + fetch_all_pages( + &self.client, + &url, + self.settings.retry_settings, + pagination.pagination, + 10, + ) + .await + } else { + send_get_request(&self.client, url, self.settings.retry_settings).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pagination::Pagination; + use httpmock::{Method::GET, MockServer}; + + #[tokio::test] + async fn transaction_endpoints_send_cursor_range() { + let server = MockServer::start(); + let mut settings = BlockFrostSettings::new(); + settings.base_url = Some(server.base_url()); + let api = BlockfrostAPI::new("test", settings); + + let account_mock = server.mock(|when, then| { + when.method(GET) + .path("/accounts/stake_test/transactions") + .query_param("from", "8929261") + .query_param("to", "9999269:10"); + then.status(200) + .header("Content-Type", "application/json") + .body("[]"); + }); + let address_mock = server.mock(|when, then| { + when.method(GET) + .path("/addresses/addr_test/transactions") + .query_param("from", "8929261") + .query_param("to", "9999269:10"); + then.status(200) + .header("Content-Type", "application/json") + .body("[]"); + }); + let asset_mock = server.mock(|when, then| { + when.method(GET) + .path("/assets/asset_test/transactions") + .query_param("from", "8929261") + .query_param("to", "9999269:10"); + then.status(200) + .header("Content-Type", "application/json") + .body("[]"); + }); + + let pagination = Pagination::default().with_range("8929261", "9999269:10"); + api.accounts_transactions("stake_test", pagination.clone()) + .await + .unwrap(); + api.addresses_transactions("addr_test", pagination.clone()) + .await + .unwrap(); + api.assets_transactions("asset_test", pagination) + .await + .unwrap(); + + account_mock.assert(); + address_mock.assert(); + asset_mock.assert(); + } } diff --git a/src/lib.rs b/src/lib.rs index 8d5b5d0..bd689b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub use api::*; pub use blockfrost_openapi; pub use error::*; pub use ipfs::BlockfrostIPFS; +pub use pagination::CursorPagination; pub use pagination::Order; pub use pagination::Pagination; pub use settings::*; diff --git a/src/pagination.rs b/src/pagination.rs index 58a6298..f613466 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -36,6 +36,27 @@ impl Pagination { } } + /// Add an inclusive lower block cursor to this pagination. + /// + /// Transaction endpoints accept either a block number (`"8929261"`) or a block number and + /// transaction index (`"8929261:10"`). + pub fn with_from(self, from: impl Into) -> CursorPagination { + CursorPagination::from(self).with_from(from) + } + + /// Add an inclusive upper block cursor to this pagination. + /// + /// Transaction endpoints accept either a block number (`"9999269"`) or a block number and + /// transaction index (`"9999269:10"`). + pub fn with_to(self, to: impl Into) -> CursorPagination { + CursorPagination::from(self).with_to(to) + } + + /// Add inclusive lower and upper block cursors to this pagination. + pub fn with_range(self, from: impl Into, to: impl Into) -> CursorPagination { + CursorPagination::from(self).with_range(from, to) + } + pub fn order_to_string(&self) -> String { match self.order { Order::Asc => "asc".to_string(), @@ -49,3 +70,68 @@ pub enum Order { Asc, Desc, } + +/// Pagination with optional inclusive block cursors. +/// +/// This is supported by the account, address, and asset transaction endpoints. Construct it from +/// [`Pagination`] with [`Pagination::with_from`], [`Pagination::with_to`], or +/// [`Pagination::with_range`]. A plain [`Pagination`] remains accepted by those endpoints. +#[derive(Clone)] +pub struct CursorPagination { + pub pagination: Pagination, + pub from: Option, + pub to: Option, +} + +impl CursorPagination { + pub fn new(pagination: Pagination) -> Self { + Self { + pagination, + from: None, + to: None, + } + } + + pub fn with_from(mut self, from: impl Into) -> Self { + self.from = Some(from.into()); + self + } + + pub fn with_to(mut self, to: impl Into) -> Self { + self.to = Some(to.into()); + self + } + + pub fn with_range(mut self, from: impl Into, to: impl Into) -> Self { + self.from = Some(from.into()); + self.to = Some(to.into()); + self + } +} + +impl From for CursorPagination { + fn from(pagination: Pagination) -> Self { + Self::new(pagination) + } +} + +impl Default for CursorPagination { + fn default() -> Self { + Self::new(Pagination::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pagination_builds_cursor_range() { + let cursor = Pagination::default().with_range("8929261", "9999269:10"); + + assert_eq!(cursor.from.as_deref(), Some("8929261")); + assert_eq!(cursor.to.as_deref(), Some("9999269:10")); + assert_eq!(cursor.pagination.page, DEFAULT_PAGINATION_PAGE_COUNT); + assert_eq!(cursor.pagination.count, DEFAULT_PAGINATION_PAGE_ITEMS_COUNT); + } +} diff --git a/src/url.rs b/src/url.rs index 0d2bfe8..34c32cc 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,5 +1,6 @@ use crate::{ - pagination::Pagination, CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL, + pagination::{CursorPagination, Pagination}, + CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL, }; use std::error::Error; use url::{form_urlencoded, Url as UrlI}; @@ -31,11 +32,38 @@ impl Url { Ok(url.to_string()) } + pub fn from_cursor_paginated_endpoint( + base_url: &str, endpoint_url: &str, pagination: &CursorPagination, + ) -> Result> { + let mut url = Self::create_base_url(base_url, endpoint_url)?; + let mut query_pairs = form_urlencoded::Serializer::new(String::new()); + + query_pairs.append_pair("page", pagination.pagination.page.to_string().as_str()); + query_pairs.append_pair("count", pagination.pagination.count.to_string().as_str()); + query_pairs.append_pair("order", pagination.pagination.order_to_string().as_str()); + + if let Some(from) = &pagination.from { + query_pairs.append_pair("from", from); + } + if let Some(to) = &pagination.to { + query_pairs.append_pair("to", to); + } + + url.set_query(Some(&query_pairs.finish())); + + Ok(url.to_string()) + } + pub fn generate_batch( url: &str, batch_size: usize, start: usize, pagination: Pagination, ) -> Result, Box> { let mut result = Vec::new(); let url = UrlI::parse(url)?; + let extra_query_pairs: Vec<(String, String)> = url + .query_pairs() + .filter(|(name, _)| name != "page" && name != "count" && name != "order") + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); for page in start..(start + batch_size) { let mut query_pairs = form_urlencoded::Serializer::new(String::new()); @@ -43,6 +71,7 @@ impl Url { query_pairs.append_pair("page", page.to_string().as_str()); query_pairs.append_pair("count", pagination.count.to_string().as_str()); query_pairs.append_pair("order", pagination.order_to_string().as_str()); + query_pairs.extend_pairs(&extra_query_pairs); let query = query_pairs.finish(); @@ -81,7 +110,7 @@ impl Url { #[cfg(test)] mod tests { use super::*; - use crate::pagination::{Order, Pagination}; + use crate::pagination::{CursorPagination, Order, Pagination}; use crate::{CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL}; use rstest::rstest; @@ -142,6 +171,38 @@ mod tests { assert_eq!(result, expected); } + #[rstest] + #[case( + Some("8929261"), + Some("9999269:10"), + "http://example.com/api/items?page=2&count=5&order=desc&from=8929261&to=9999269%3A10" + )] + #[case( + Some("8929261:3"), + None, + "http://example.com/api/items?page=2&count=5&order=desc&from=8929261%3A3" + )] + #[case( + None, + Some("9999269"), + "http://example.com/api/items?page=2&count=5&order=desc&to=9999269" + )] + fn test_from_cursor_paginated_endpoint( + #[case] from: Option<&str>, #[case] to: Option<&str>, #[case] expected: &str, + ) { + let pagination = CursorPagination { + pagination: Pagination::new(Order::Desc, 2, 5), + from: from.map(String::from), + to: to.map(String::from), + }; + + let result = + Url::from_cursor_paginated_endpoint("http://example.com", "api/items", &pagination) + .unwrap(); + + assert_eq!(result, expected); + } + #[rstest] #[case("http://example.com/api/data", 3, 1, 10, Order::Asc, vec![ @@ -164,6 +225,26 @@ mod tests { assert_eq!(urls, expected); } + #[test] + fn test_generate_batch_preserves_cursor_query_parameters() { + let pagination = Pagination::new(Order::Asc, 1, 10); + let urls = Url::generate_batch( + "http://example.com/api/data?page=1&count=10&order=asc&from=8929261&to=9999269%3A10", + 2, + 1, + pagination, + ) + .unwrap(); + + assert_eq!( + urls, + vec![ + "http://example.com/api/data?page=1&count=10&order=asc&from=8929261&to=9999269%3A10", + "http://example.com/api/data?page=2&count=10&order=asc&from=8929261&to=9999269%3A10", + ] + ); + } + #[rstest] #[case( "http://example.com/api/data", From 02cad12c1e893c10143d5dec84acad75ff95cfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Volek?= Date: Wed, 29 Jul 2026 13:45:54 +0200 Subject: [PATCH 2/2] review: fix pagination --- CHANGELOG.md | 14 +++- README.md | 33 +++++++++ src/api/endpoints/accounts.rs | 7 +- src/api/endpoints/addresses.rs | 7 +- src/api/endpoints/assets.rs | 7 +- src/api/mod.rs | 20 +++-- src/lib.rs | 2 +- src/pagination.rs | 131 ++++++++++++++++++++------------- src/url.rs | 94 +++++++++++++---------- 9 files changed, 207 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd98e8d..ebb9d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Inclusive `from`/`to` block cursor support for account, address, and asset transaction endpoints +- `Pagination::with_from`, `Pagination::with_to` and `Pagination::with_range` for inclusive `from`/`to` + block cursors, plus the `BlockCursor` type they take +- Cursors are only sent by the three endpoints that accept them (`accounts_transactions`, + `addresses_transactions`, `assets_transactions`). Every other paginated endpoint silently drops + them and returns an unfiltered result rather than an error, so keep cursors on a `Pagination` + handed only to those three +- Cursors are preserved across pages when `fetch_all` is set + +### Changed + +- `Pagination` gained the public `from` and `to` fields. Endpoint signatures are unchanged and + `Pagination` stays `Copy`, so only code constructing it via a struct literal needs updating + (add `..Default::default()`); `Pagination::default()`, `::new()` and `::all()` are unaffected ## 1.2.4 - 2026-05-28 diff --git a/README.md b/README.md index c4efe02..cc638fa 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,39 @@ async fn main() -> blockfrost::BlockfrostResult<()> { } ``` +## Pagination + +Every paginated endpoint takes a `Pagination`, which controls `page`, `count` and `order`: + +```rust +use blockfrost::{Order, Pagination}; + +let pagination = Pagination::new(Order::Desc, 1, 100); +let everything = Pagination::all(); // fetch every page +``` + +### Block range cursors + +Three endpoints additionally accept inclusive `from`/`to` block cursors: + +- `accounts_transactions` +- `addresses_transactions` +- `assets_transactions` + +```rust +use blockfrost::{BlockCursor, Pagination}; + +// a plain block height, or a block height with a transaction index +let pagination = Pagination::default().with_range(8929261, BlockCursor::tx(9999269, 10)); +``` + +Cursors also accept `BlockCursor::block(..)`, `BlockCursor::tx(..)` and `"8929261:10".parse()`. +They are preserved across pages when `fetch_all` is set. + +> **Note:** every other paginated endpoint ignores `from`/`to`. Passing a `Pagination` that carries +> cursors to, say, `accounts_utxos` returns the full unfiltered list rather than an error, so keep +> cursors on a `Pagination` you only hand to the three endpoints above. + [`examples/`]: https://github.com/blockfrost/blockfrost-rust/tree/master/examples [`all_requests.rs`]: https://github.com/blockfrost/blockfrost-rust/blob/master/examples/all_requests.rs [`ipfs.rs`]: https://github.com/blockfrost/blockfrost-rust/blob/master/examples/ipfs.rs diff --git a/src/api/endpoints/accounts.rs b/src/api/endpoints/accounts.rs index 14f261d..aac31c7 100644 --- a/src/api/endpoints/accounts.rs +++ b/src/api/endpoints/accounts.rs @@ -141,13 +141,14 @@ impl BlockfrostAPI { /// Transactions of a specific account. /// /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to - /// restrict results to an inclusive block range. + /// restrict results to an inclusive block range. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn accounts_transactions( - &self, stake_address: &str, pagination: impl Into, + &self, stake_address: &str, pagination: Pagination, ) -> BlockfrostResult> { self.call_cursor_paged_endpoint( format!("/accounts/{stake_address}/transactions").as_str(), - pagination.into(), + pagination, ) .await } diff --git a/src/api/endpoints/addresses.rs b/src/api/endpoints/addresses.rs index 19f506c..21c3199 100644 --- a/src/api/endpoints/addresses.rs +++ b/src/api/endpoints/addresses.rs @@ -50,13 +50,14 @@ impl BlockfrostAPI { /// Return the transactions for a specific address. /// /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to - /// restrict results to an inclusive block range. + /// restrict results to an inclusive block range. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn addresses_transactions( - &self, address: &str, pagination: impl Into, + &self, address: &str, pagination: Pagination, ) -> BlockfrostResult> { self.call_cursor_paged_endpoint( format!("/addresses/{address}/transactions").as_str(), - pagination.into(), + pagination, ) .await } diff --git a/src/api/endpoints/assets.rs b/src/api/endpoints/assets.rs index 54c03dc..ab166a9 100644 --- a/src/api/endpoints/assets.rs +++ b/src/api/endpoints/assets.rs @@ -28,13 +28,14 @@ impl BlockfrostAPI { /// Return the transactions for a specific asset. /// /// Use [`Pagination::with_from`], [`Pagination::with_to`], or [`Pagination::with_range`] to - /// restrict results to an inclusive block range. + /// restrict results to an inclusive block range. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn assets_transactions( - &self, asset: &str, pagination: impl Into, + &self, asset: &str, pagination: Pagination, ) -> BlockfrostResult> { self.call_cursor_paged_endpoint( format!("/assets/{asset}/transactions").as_str(), - pagination.into(), + pagination, ) .await } diff --git a/src/api/mod.rs b/src/api/mod.rs index fe3dd74..d3a714e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ pub(super) mod endpoints; use crate::{ - pagination::{CursorPagination, Pagination}, + pagination::Pagination, request::{fetch_all_pages, send_get_request}, url::Url, utils::build_header_map, @@ -80,21 +80,24 @@ impl BlockfrostAPI { } } + /// Same as [`Self::call_paged_endpoint`], but forwards `from`/`to` block cursors. + /// + /// Reserved for the endpoints that accept them; see [`crate::BlockCursor`]. async fn call_cursor_paged_endpoint( - &self, url_endpoint: &str, pagination: CursorPagination, + &self, url_endpoint: &str, pagination: Pagination, ) -> Result, BlockfrostError> where T: for<'de> serde::Deserialize<'de> + serde::de::DeserializeOwned, { let url = - Url::from_cursor_paginated_endpoint(self.base_url.as_str(), url_endpoint, &pagination)?; + Url::from_cursor_paginated_endpoint(self.base_url.as_str(), url_endpoint, pagination)?; - if pagination.pagination.fetch_all { + if pagination.fetch_all { fetch_all_pages( &self.client, &url, self.settings.retry_settings, - pagination.pagination, + pagination, 10, ) .await @@ -145,11 +148,12 @@ mod tests { .body("[]"); }); - let pagination = Pagination::default().with_range("8929261", "9999269:10"); - api.accounts_transactions("stake_test", pagination.clone()) + // A single Copy `Pagination` is reused across all three calls. + let pagination = Pagination::default().with_range(8929261, (9999269, 10)); + api.accounts_transactions("stake_test", pagination) .await .unwrap(); - api.addresses_transactions("addr_test", pagination.clone()) + api.addresses_transactions("addr_test", pagination) .await .unwrap(); api.assets_transactions("asset_test", pagination) diff --git a/src/lib.rs b/src/lib.rs index bd689b5..9fc5465 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ pub use api::*; pub use blockfrost_openapi; pub use error::*; pub use ipfs::BlockfrostIPFS; -pub use pagination::CursorPagination; +pub use pagination::BlockCursor; pub use pagination::Order; pub use pagination::Pagination; pub use settings::*; diff --git a/src/pagination.rs b/src/pagination.rs index f613466..d60e1ba 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -1,4 +1,7 @@ use crate::{DEFAULT_ORDER, DEFAULT_PAGINATION_PAGE_COUNT, DEFAULT_PAGINATION_PAGE_ITEMS_COUNT}; +use std::fmt; +use std::num::ParseIntError; +use std::str::FromStr; #[derive(Clone, Copy)] pub struct Pagination { @@ -6,6 +9,8 @@ pub struct Pagination { pub count: usize, pub page: usize, pub order: Order, + pub from: Option, + pub to: Option, } impl Default for Pagination { @@ -15,6 +20,8 @@ impl Default for Pagination { count: DEFAULT_PAGINATION_PAGE_ITEMS_COUNT, page: DEFAULT_PAGINATION_PAGE_COUNT, order: DEFAULT_ORDER, + from: None, + to: None, } } } @@ -26,6 +33,8 @@ impl Pagination { order, page, count, + from: None, + to: None, } } @@ -36,25 +45,18 @@ impl Pagination { } } - /// Add an inclusive lower block cursor to this pagination. - /// - /// Transaction endpoints accept either a block number (`"8929261"`) or a block number and - /// transaction index (`"8929261:10"`). - pub fn with_from(self, from: impl Into) -> CursorPagination { - CursorPagination::from(self).with_from(from) + pub fn with_from(mut self, from: impl Into) -> Self { + self.from = Some(from.into()); + self } - /// Add an inclusive upper block cursor to this pagination. - /// - /// Transaction endpoints accept either a block number (`"9999269"`) or a block number and - /// transaction index (`"9999269:10"`). - pub fn with_to(self, to: impl Into) -> CursorPagination { - CursorPagination::from(self).with_to(to) + pub fn with_to(mut self, to: impl Into) -> Self { + self.to = Some(to.into()); + self } - /// Add inclusive lower and upper block cursors to this pagination. - pub fn with_range(self, from: impl Into, to: impl Into) -> CursorPagination { - CursorPagination::from(self).with_range(from, to) + pub fn with_range(self, from: impl Into, to: impl Into) -> Self { + self.with_from(from).with_to(to) } pub fn order_to_string(&self) -> String { @@ -71,53 +73,59 @@ pub enum Order { Desc, } -/// Pagination with optional inclusive block cursors. -/// -/// This is supported by the account, address, and asset transaction endpoints. Construct it from -/// [`Pagination`] with [`Pagination::with_from`], [`Pagination::with_to`], or -/// [`Pagination::with_range`]. A plain [`Pagination`] remains accepted by those endpoints. -#[derive(Clone)] -pub struct CursorPagination { - pub pagination: Pagination, - pub from: Option, - pub to: Option, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlockCursor { + pub block_height: u64, + pub tx_index: Option, } -impl CursorPagination { - pub fn new(pagination: Pagination) -> Self { +impl BlockCursor { + pub fn block(block_height: u64) -> Self { Self { - pagination, - from: None, - to: None, + block_height, + tx_index: None, } } - pub fn with_from(mut self, from: impl Into) -> Self { - self.from = Some(from.into()); - self + pub fn tx(block_height: u64, tx_index: u32) -> Self { + Self { + block_height, + tx_index: Some(tx_index), + } } +} - pub fn with_to(mut self, to: impl Into) -> Self { - self.to = Some(to.into()); - self +impl fmt::Display for BlockCursor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.tx_index { + Some(tx_index) => write!(f, "{}:{}", self.block_height, tx_index), + None => write!(f, "{}", self.block_height), + } } +} - pub fn with_range(mut self, from: impl Into, to: impl Into) -> Self { - self.from = Some(from.into()); - self.to = Some(to.into()); - self +impl From for BlockCursor { + fn from(block_height: u64) -> Self { + Self::block(block_height) } } -impl From for CursorPagination { - fn from(pagination: Pagination) -> Self { - Self::new(pagination) +impl From<(u64, u32)> for BlockCursor { + fn from((block_height, tx_index): (u64, u32)) -> Self { + Self::tx(block_height, tx_index) } } -impl Default for CursorPagination { - fn default() -> Self { - Self::new(Pagination::default()) +impl FromStr for BlockCursor { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + match s.split_once(':') { + Some((block_height, tx_index)) => { + Ok(Self::tx(block_height.parse()?, tx_index.parse()?)) + } + None => Ok(Self::block(s.parse()?)), + } } } @@ -127,11 +135,32 @@ mod tests { #[test] fn pagination_builds_cursor_range() { - let cursor = Pagination::default().with_range("8929261", "9999269:10"); + let pagination = Pagination::default().with_range(8929261, (9999269, 10)); + + assert_eq!(pagination.from, Some(BlockCursor::block(8929261))); + assert_eq!(pagination.to, Some(BlockCursor::tx(9999269, 10))); + assert_eq!(pagination.page, DEFAULT_PAGINATION_PAGE_COUNT); + assert_eq!(pagination.count, DEFAULT_PAGINATION_PAGE_ITEMS_COUNT); + } - assert_eq!(cursor.from.as_deref(), Some("8929261")); - assert_eq!(cursor.to.as_deref(), Some("9999269:10")); - assert_eq!(cursor.pagination.page, DEFAULT_PAGINATION_PAGE_COUNT); - assert_eq!(cursor.pagination.count, DEFAULT_PAGINATION_PAGE_ITEMS_COUNT); + #[test] + fn pagination_stays_copy() { + let pagination = Pagination::default().with_from(8929261); + let copied = pagination; + + assert_eq!(pagination.from, copied.from); + } + + #[test] + fn block_cursor_display() { + assert_eq!(BlockCursor::block(8929261).to_string(), "8929261"); + assert_eq!(BlockCursor::tx(9999269, 10).to_string(), "9999269:10"); + } + + #[test] + fn block_cursor_from_str() { + assert_eq!("8929261".parse(), Ok(BlockCursor::block(8929261))); + assert_eq!("9999269:10".parse(), Ok(BlockCursor::tx(9999269, 10))); + assert!("nope".parse::().is_err()); } } diff --git a/src/url.rs b/src/url.rs index 34c32cc..ca300e6 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,6 +1,5 @@ use crate::{ - pagination::{CursorPagination, Pagination}, - CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL, + pagination::Pagination, CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL, }; use std::error::Error; use url::{form_urlencoded, Url as UrlI}; @@ -15,41 +14,46 @@ impl Url { Ok(url.to_string()) } + /// Build a paginated URL, ignoring any block cursors set on `pagination`. + /// + /// This is the variant used by every endpoint that does not accept `from`/`to`. pub fn from_paginated_endpoint( base_url: &str, endpoint_url: &str, pagination: Pagination, ) -> Result> { - let mut url = Self::create_base_url(base_url, endpoint_url)?; - let mut query_pairs = form_urlencoded::Serializer::new(String::new()); - - query_pairs.append_pair("page", pagination.page.to_string().as_str()); - query_pairs.append_pair("count", pagination.count.to_string().as_str()); - query_pairs.append_pair("order", pagination.order_to_string().as_str()); - - let query = query_pairs.finish(); - - url.set_query(Some(&query)); - - Ok(url.to_string()) + Self::build_paginated_url(base_url, endpoint_url, pagination, false) } + /// Build a paginated URL, appending `from`/`to` when they are set on `pagination`. + /// + /// Only endpoints documented as accepting block cursors use this variant. pub fn from_cursor_paginated_endpoint( - base_url: &str, endpoint_url: &str, pagination: &CursorPagination, + base_url: &str, endpoint_url: &str, pagination: Pagination, + ) -> Result> { + Self::build_paginated_url(base_url, endpoint_url, pagination, true) + } + + fn build_paginated_url( + base_url: &str, endpoint_url: &str, pagination: Pagination, with_cursors: bool, ) -> Result> { let mut url = Self::create_base_url(base_url, endpoint_url)?; let mut query_pairs = form_urlencoded::Serializer::new(String::new()); - query_pairs.append_pair("page", pagination.pagination.page.to_string().as_str()); - query_pairs.append_pair("count", pagination.pagination.count.to_string().as_str()); - query_pairs.append_pair("order", pagination.pagination.order_to_string().as_str()); + query_pairs.append_pair("page", pagination.page.to_string().as_str()); + query_pairs.append_pair("count", pagination.count.to_string().as_str()); + query_pairs.append_pair("order", pagination.order_to_string().as_str()); - if let Some(from) = &pagination.from { - query_pairs.append_pair("from", from); - } - if let Some(to) = &pagination.to { - query_pairs.append_pair("to", to); + if with_cursors { + if let Some(from) = pagination.from { + query_pairs.append_pair("from", from.to_string().as_str()); + } + if let Some(to) = pagination.to { + query_pairs.append_pair("to", to.to_string().as_str()); + } } - url.set_query(Some(&query_pairs.finish())); + let query = query_pairs.finish(); + + url.set_query(Some(&query)); Ok(url.to_string()) } @@ -110,7 +114,7 @@ impl Url { #[cfg(test)] mod tests { use super::*; - use crate::pagination::{CursorPagination, Order, Pagination}; + use crate::pagination::{BlockCursor, Order, Pagination}; use crate::{CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL}; use rstest::rstest; @@ -165,7 +169,7 @@ mod tests { page, count, order, - fetch_all: false, + ..Default::default() }; let result = Url::from_paginated_endpoint(base_url, endpoint_url, pagination).unwrap(); assert_eq!(result, expected); @@ -173,36 +177,50 @@ mod tests { #[rstest] #[case( - Some("8929261"), - Some("9999269:10"), + Some(BlockCursor::block(8929261)), + Some(BlockCursor::tx(9999269, 10)), "http://example.com/api/items?page=2&count=5&order=desc&from=8929261&to=9999269%3A10" )] #[case( - Some("8929261:3"), + Some(BlockCursor::tx(8929261, 3)), None, "http://example.com/api/items?page=2&count=5&order=desc&from=8929261%3A3" )] #[case( None, - Some("9999269"), + Some(BlockCursor::block(9999269)), "http://example.com/api/items?page=2&count=5&order=desc&to=9999269" )] fn test_from_cursor_paginated_endpoint( - #[case] from: Option<&str>, #[case] to: Option<&str>, #[case] expected: &str, + #[case] from: Option, #[case] to: Option, #[case] expected: &str, ) { - let pagination = CursorPagination { - pagination: Pagination::new(Order::Desc, 2, 5), - from: from.map(String::from), - to: to.map(String::from), + let pagination = Pagination { + from, + to, + ..Pagination::new(Order::Desc, 2, 5) }; let result = - Url::from_cursor_paginated_endpoint("http://example.com", "api/items", &pagination) + Url::from_cursor_paginated_endpoint("http://example.com", "api/items", pagination) .unwrap(); assert_eq!(result, expected); } + /// Non-cursor endpoints must never leak `from`/`to`, even when they are set. + #[test] + fn test_from_paginated_endpoint_ignores_cursors() { + let pagination = Pagination::new(Order::Desc, 2, 5).with_range(8929261, (9999269, 10)); + + let result = + Url::from_paginated_endpoint("http://example.com", "api/items", pagination).unwrap(); + + assert_eq!( + result, + "http://example.com/api/items?page=2&count=5&order=desc" + ); + } + #[rstest] #[case("http://example.com/api/data", 3, 1, 10, Order::Asc, vec![ @@ -218,7 +236,7 @@ mod tests { page: 0, count, order, - fetch_all: false, + ..Default::default() }; let urls = Url::generate_batch(base, batch_size, page_start, pagination).unwrap(); let expected: Vec = expected.into_iter().map(String::from).collect(); @@ -285,7 +303,7 @@ mod tests { page: 0, count, order, - fetch_all: false, + ..Default::default() }; let urls = Url::generate_batch(base, batch_size, page_start, pagination).unwrap(); let expected: Vec = expected.into_iter().map(String::from).collect();