diff --git a/CHANGELOG.md b/CHANGELOG.md index 528aae7..ebb9d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `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 ### Added 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 0772401..aac31c7 100644 --- a/src/api/endpoints/accounts.rs +++ b/src/api/endpoints/accounts.rs @@ -139,10 +139,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. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn accounts_transactions( &self, stake_address: &str, pagination: Pagination, ) -> BlockfrostResult> { - self.call_paged_endpoint( + self.call_cursor_paged_endpoint( format!("/accounts/{stake_address}/transactions").as_str(), pagination, ) diff --git a/src/api/endpoints/addresses.rs b/src/api/endpoints/addresses.rs index 9de1c33..21c3199 100644 --- a/src/api/endpoints/addresses.rs +++ b/src/api/endpoints/addresses.rs @@ -48,10 +48,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. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn addresses_transactions( &self, address: &str, pagination: Pagination, ) -> BlockfrostResult> { - self.call_paged_endpoint( + self.call_cursor_paged_endpoint( format!("/addresses/{address}/transactions").as_str(), pagination, ) diff --git a/src/api/endpoints/assets.rs b/src/api/endpoints/assets.rs index b2d6148..ab166a9 100644 --- a/src/api/endpoints/assets.rs +++ b/src/api/endpoints/assets.rs @@ -26,11 +26,18 @@ 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. This is one of the three endpoints that + /// accept block cursors; see [`BlockCursor`]. pub async fn assets_transactions( &self, asset: &str, pagination: Pagination, ) -> 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, + ) + .await } /// Return the addresses holding a specific asset. diff --git a/src/api/mod.rs b/src/api/mod.rs index e3ab86c..d3a714e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -79,4 +79,89 @@ impl BlockfrostAPI { send_get_request(&self.client, url, self.settings.retry_settings).await } } + + /// 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: 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)?; + + if pagination.fetch_all { + fetch_all_pages( + &self.client, + &url, + self.settings.retry_settings, + 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("[]"); + }); + + // 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) + .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..9fc5465 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::BlockCursor; pub use pagination::Order; pub use pagination::Pagination; pub use settings::*; diff --git a/src/pagination.rs b/src/pagination.rs index 58a6298..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,6 +45,20 @@ impl Pagination { } } + 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(self, from: impl Into, to: impl Into) -> Self { + self.with_from(from).with_to(to) + } + pub fn order_to_string(&self) -> String { match self.order { Order::Asc => "asc".to_string(), @@ -49,3 +72,95 @@ pub enum Order { Asc, Desc, } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlockCursor { + pub block_height: u64, + pub tx_index: Option, +} + +impl BlockCursor { + pub fn block(block_height: u64) -> Self { + Self { + block_height, + tx_index: None, + } + } + + pub fn tx(block_height: u64, tx_index: u32) -> Self { + Self { + block_height, + tx_index: Some(tx_index), + } + } +} + +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), + } + } +} + +impl From for BlockCursor { + fn from(block_height: u64) -> Self { + Self::block(block_height) + } +} + +impl From<(u64, u32)> for BlockCursor { + fn from((block_height, tx_index): (u64, u32)) -> Self { + Self::tx(block_height, tx_index) + } +} + +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()?)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pagination_builds_cursor_range() { + 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); + } + + #[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 0d2bfe8..ca300e6 100644 --- a/src/url.rs +++ b/src/url.rs @@ -14,8 +14,26 @@ 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> { + 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: 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()); @@ -24,6 +42,15 @@ impl Url { query_pairs.append_pair("count", pagination.count.to_string().as_str()); query_pairs.append_pair("order", pagination.order_to_string().as_str()); + 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()); + } + } + let query = query_pairs.finish(); url.set_query(Some(&query)); @@ -36,6 +63,11 @@ impl Url { ) -> 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 +75,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 +114,7 @@ impl Url { #[cfg(test)] mod tests { use super::*; - use crate::pagination::{Order, Pagination}; + use crate::pagination::{BlockCursor, Order, Pagination}; use crate::{CARDANO_MAINNET_URL, CARDANO_PREPROD_URL, CARDANO_PREVIEW_URL}; use rstest::rstest; @@ -136,12 +169,58 @@ 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); } + #[rstest] + #[case( + 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(BlockCursor::tx(8929261, 3)), + None, + "http://example.com/api/items?page=2&count=5&order=desc&from=8929261%3A3" + )] + #[case( + None, + 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, #[case] to: Option, #[case] expected: &str, + ) { + let pagination = Pagination { + from, + to, + ..Pagination::new(Order::Desc, 2, 5) + }; + + let result = + 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![ @@ -157,13 +236,33 @@ 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(); 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", @@ -204,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();