Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/api/endpoints/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<AccountTransactionsContentInner>> {
self.call_paged_endpoint(
self.call_cursor_paged_endpoint(
format!("/accounts/{stake_address}/transactions").as_str(),
pagination,
)
Comment thread
vladimirvolek marked this conversation as resolved.
Expand Down
6 changes: 5 additions & 1 deletion src/api/endpoints/addresses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<AddressTransactionsContentInner>> {
self.call_paged_endpoint(
self.call_cursor_paged_endpoint(
format!("/addresses/{address}/transactions").as_str(),
pagination,
)
Comment thread
vladimirvolek marked this conversation as resolved.
Expand Down
11 changes: 9 additions & 2 deletions src/api/endpoints/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<AssetTransactionsInner>> {
self.call_paged_endpoint(format!("/assets/{asset}/transactions").as_str(), pagination)
.await
self.call_cursor_paged_endpoint(
format!("/assets/{asset}/transactions").as_str(),
pagination,
)
Comment thread
vladimirvolek marked this conversation as resolved.
.await
}

/// Return the addresses holding a specific asset.
Expand Down
85 changes: 85 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
&self, url_endpoint: &str, pagination: Pagination,
) -> Result<Vec<T>, 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();
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
115 changes: 115 additions & 0 deletions src/pagination.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
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 {
pub fetch_all: bool,
pub count: usize,
pub page: usize,
pub order: Order,
pub from: Option<BlockCursor>,
pub to: Option<BlockCursor>,
}

impl Default for Pagination {
Expand All @@ -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,
}
}
}
Expand All @@ -26,6 +33,8 @@ impl Pagination {
order,
page,
count,
from: None,
to: None,
}
}

Expand All @@ -36,6 +45,20 @@ impl Pagination {
}
}

pub fn with_from(mut self, from: impl Into<BlockCursor>) -> Self {
self.from = Some(from.into());
self
}

pub fn with_to(mut self, to: impl Into<BlockCursor>) -> Self {
self.to = Some(to.into());
self
}

pub fn with_range(self, from: impl Into<BlockCursor>, to: impl Into<BlockCursor>) -> Self {
self.with_from(from).with_to(to)
}

pub fn order_to_string(&self) -> String {
match self.order {
Order::Asc => "asc".to_string(),
Expand All @@ -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<u32>,
}

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<u64> 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<Self, Self::Err> {
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::<BlockCursor>().is_err());
}
}
Loading
Loading