diff --git a/contract/Cargo.toml b/contract/Cargo.toml index 3727bb57..44bf1a14 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" # Web framework axum = "0.7" tokio = { version = "1.35", features = ["full"] } -tower = "0.4" +tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["trace", "cors", "request-id"] } url = "2" futures = "0.3" @@ -45,7 +45,7 @@ anyhow = "1.0" # Crypto sha2 = "0.10" hex = "0.4" -chrono = "0.4.43" +chrono = { version = "0.4.43", features = ["serde"] } base64 = "0.22" rand = "0.8" uuid = { version = "1", features = ["v4"] } diff --git a/contract/src/lib.rs b/contract/src/lib.rs index d0a1f87d..90359df1 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -15,7 +15,7 @@ pub mod webhook; use axum::{ body::Body, - extract::{Path, State}, + extract::{Path, Query, State}, http::{HeaderName, Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, @@ -37,6 +37,7 @@ use cache::CacheBackend; use event::Event; use hash_validator::{HashValidator, ValidationError as HashValidationError}; use metrics::MetricsRegistry; +use module::ownership_chain::pagination; use stellar::{derive_account_id, StellarClient, TransactionRecord}; /// Header used to correlate a single logical operation across the NestJS @@ -145,8 +146,12 @@ pub struct HealthResponse { pub struct HistoryResponse { pub document_hash: String, pub transactions: Vec, + /// Total records in the document's chain, independent of the page returned. pub count: usize, pub cached: bool, + /// Cursor for the following page when the caller asked for one (#1346); + /// `null` when the whole chain was returned or it is exhausted. + pub next_cursor: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -523,6 +528,40 @@ pub async fn record_transfer( })) } +/// Default number of ownership-chain records returned per page (#1346). +pub const DEFAULT_CHAIN_PAGE_SIZE: usize = 20; + +/// Upper bound a caller may request for `page_size` (#1346). +pub const MAX_CHAIN_PAGE_SIZE: usize = 200; + +/// Query parameters for a cursor-paginated ownership-chain lookup (#1346). +#[derive(Debug, Deserialize)] +pub struct ChainHistoryQuery { + /// Offset into the chain to start from; 0 when omitted. + pub cursor: Option, + /// Records to return; defaults to [`DEFAULT_CHAIN_PAGE_SIZE`], capped at + /// [`MAX_CHAIN_PAGE_SIZE`]. + pub page_size: Option, +} + +/// Resolve the requested page, or `None` when the caller asked for no paging. +/// +/// `None` keeps the pre-existing response shape (the full array), so clients +/// written before #1346 are unaffected; passing either parameter opts in. +fn pagination_params(query: &ChainHistoryQuery) -> Option<(usize, usize)> { + if query.cursor.is_none() && query.page_size.is_none() { + return None; + } + + Some(( + query.cursor.unwrap_or(0), + query + .page_size + .unwrap_or(DEFAULT_CHAIN_PAGE_SIZE) + .clamp(1, MAX_CHAIN_PAGE_SIZE), + )) +} + /// GET /transfer/:document_hash — retrieve transfer history for a document. pub async fn get_transfer_history( State(state): State, @@ -636,6 +675,7 @@ pub async fn verify_document_by_hash( pub async fn verify_document_history( State(state): State, Path(hash): Path, + Query(query): Query, ) -> Response { let normalized_hash = HashValidator::normalize(&hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { @@ -656,11 +696,22 @@ pub async fn verify_document_history( let count = transactions.len(); let cached = !transactions.is_empty(); + // #1346: page the chain only when the caller asks for it, so existing + // clients keep receiving the full list they always got. + let (transactions, next_cursor) = match pagination_params(&query) { + None => (transactions, None), + Some((cursor, page_size)) => { + let page = pagination::paginate(&transactions, cursor, page_size); + (page.items, page.next_cursor) + } + }; + Json(HistoryResponse { document_hash: normalized_hash, transactions, count, cached, + next_cursor, }) .into_response() } diff --git a/contract/src/module/ownership_chain/mod.rs b/contract/src/module/ownership_chain/mod.rs index 597c608f..fdc86ef8 100644 --- a/contract/src/module/ownership_chain/mod.rs +++ b/contract/src/module/ownership_chain/mod.rs @@ -7,6 +7,8 @@ //! Routes wired in `lib.rs`: //! GET /module/chain/:document_hash → [`chain_handler`] +pub mod pagination; + use axum::{ extract::{Path, State}, http::StatusCode, diff --git a/contract/src/module/ownership_chain/pagination.rs b/contract/src/module/ownership_chain/pagination.rs index a4407763..ca23981a 100644 --- a/contract/src/module/ownership_chain/pagination.rs +++ b/contract/src/module/ownership_chain/pagination.rs @@ -1,14 +1,23 @@ -//! Cursor-based pagination for the ownership chain history response, so -//! a document with a long transfer history is served in bounded pages. +//! Cursor-based pagination for ownership-chain history responses, so a +//! document with a long transfer history is served in bounded pages (#1346). +/// One bounded page of items plus the cursor that fetches the next one. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Page { pub items: Vec, pub next_cursor: Option, } +/// Slice `items` into a single page. +/// +/// A `cursor` past the end of `items` yields an empty page instead of panicking +/// (a caller that paginates while the chain is being trimmed would otherwise get +/// a 500), and `page_size` of 0 is treated as 1 so a page always makes progress. pub fn paginate(items: &[T], cursor: usize, page_size: usize) -> Page { - let end = std::cmp::min(cursor + page_size, items.len()); - let slice = items[cursor..end].to_vec(); + let page_size = page_size.max(1); + let start = cursor.min(items.len()); + let end = std::cmp::min(start + page_size, items.len()); + let slice = items[start..end].to_vec(); let next_cursor = if end < items.len() { Some(end) } else { None }; Page { @@ -16,3 +25,61 @@ pub fn paginate(items: &[T], cursor: usize, page_size: usize) -> Page< next_cursor, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn items(n: usize) -> Vec { + (0..n).collect() + } + + #[test] + fn first_page_reports_the_next_cursor() { + let page = paginate(&items(5), 0, 2); + assert_eq!(page.items, vec![0, 1]); + assert_eq!(page.next_cursor, Some(2)); + } + + #[test] + fn last_page_has_no_next_cursor() { + let page = paginate(&items(5), 4, 2); + assert_eq!(page.items, vec![4]); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn exact_fit_has_no_next_cursor() { + let page = paginate(&items(4), 2, 2); + assert_eq!(page.items, vec![2, 3]); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn cursor_past_the_end_is_an_empty_page_not_a_panic() { + let page = paginate(&items(3), 99, 10); + assert!(page.items.is_empty()); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn cursor_at_the_end_is_an_empty_page() { + let page = paginate(&items(3), 3, 10); + assert!(page.items.is_empty()); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn zero_page_size_still_makes_progress() { + let page = paginate(&items(3), 0, 0); + assert_eq!(page.items, vec![0]); + assert_eq!(page.next_cursor, Some(1)); + } + + #[test] + fn empty_input_is_an_empty_page() { + let page: Page = paginate(&items(0), 0, 10); + assert!(page.items.is_empty()); + assert_eq!(page.next_cursor, None); + } +} diff --git a/contract/src/rate_limit.rs b/contract/src/rate_limit.rs index f7147138..5f5262a8 100644 --- a/contract/src/rate_limit.rs +++ b/contract/src/rate_limit.rs @@ -9,10 +9,14 @@ use governor::{Quota, RateLimiter}; use std::num::NonZeroU32; -pub type DefaultRateLimiter = RateLimiter< - governor::state::NotKeyed, - governor::state::InMemoryState, - governor::clock::DefaultClock, +/// `Arc`-wrapped because `AppState` derives `Clone` and `governor::RateLimiter` +/// is not itself `Clone`. +pub type DefaultRateLimiter = std::sync::Arc< + RateLimiter< + governor::state::NotKeyed, + governor::state::InMemoryState, + governor::clock::DefaultClock, + >, >; /// Build an in-memory, non-keyed rate limiter allowing `per_second` @@ -22,7 +26,7 @@ pub type DefaultRateLimiter = RateLimiter< pub fn build_rate_limiter(per_second: u32, burst: u32) -> DefaultRateLimiter { let quota = Quota::per_second(NonZeroU32::new(per_second).unwrap()) .allow_burst(NonZeroU32::new(burst).unwrap()); - RateLimiter::direct(quota) + std::sync::Arc::new(RateLimiter::direct(quota)) } #[cfg(test)] diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index 387a33eb..4ac11564 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -778,7 +778,7 @@ mod tests { when.method(GET).path(format!("/accounts/{}", TEST_ACCOUNT)); then.status(200).json_body(serde_json::json!({ "sequence": "1", - "data": { (data_key): raw_value } + "data": { (data_key.clone()): raw_value } })); }); diff --git a/contract/tests/handler_integration_tests.rs b/contract/tests/handler_integration_tests.rs index 9dee523a..8e2247de 100644 --- a/contract/tests/handler_integration_tests.rs +++ b/contract/tests/handler_integration_tests.rs @@ -400,3 +400,109 @@ async fn test_transfer_with_invalid_date_returns_400() { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + +// ── #1346: ownership-chain pagination on the mounted history endpoint ─────── + +use stellar_doc_verifier::stellar::TransactionRecord; + +const CHAIN_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +fn chain_records(n: usize) -> Vec { + (0..n) + .map(|i| TransactionRecord { + transaction_id: format!("tx-{:03}", i), + timestamp: 1_700_000_000 + i as i64, + verified: true, + }) + .collect() +} + +async fn state_with_chain(records: Vec) -> AppState { + let cache = CacheBackend::InMemory(InMemoryCache::new()); + cache + .set(&format!("history:{}", CHAIN_HASH), &records, 3600) + .await + .unwrap(); + AppState { + stellar: Arc::new(StellarClient::new("https://horizon-testnet.stellar.org")), + cache: Arc::new(cache), + metrics: Arc::new(MetricsRegistry::new()), + stellar_secret_key: SECRET.to_string(), + rate_limiter: build_rate_limiter(1000, 1000), + webhook_urls: Vec::new(), + webhook_secret: None, + } +} + +async fn get_chain( + query: &str, + records: Vec, +) -> (StatusCode, serde_json::Value) { + let router = app(state_with_chain(records).await); + let response = router + .oneshot( + Request::builder() + .uri(format!("/verify/{}{}", CHAIN_HASH, query)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn test_verify_history_without_params_returns_the_whole_chain() { + let (status, json) = get_chain("/history", chain_records(5)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["count"], 5); + assert_eq!(json["transactions"].as_array().unwrap().len(), 5); + assert!(json["next_cursor"].is_null()); +} + +#[tokio::test] +async fn test_verify_history_first_page_reports_next_cursor() { + let (status, json) = get_chain("/history?cursor=0&page_size=2", chain_records(5)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["count"], 5); + assert_eq!(json["next_cursor"], 2); + let page = json["transactions"].as_array().unwrap(); + assert_eq!(page.len(), 2); + assert_eq!(page[0]["transaction_id"], "tx-000"); +} + +#[tokio::test] +async fn test_verify_history_last_page_has_null_next_cursor() { + let (status, json) = get_chain("/history?cursor=4&page_size=2", chain_records(5)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["count"], 5); + assert_eq!(json["transactions"].as_array().unwrap().len(), 1); + assert!(json["next_cursor"].is_null()); +} + +#[tokio::test] +async fn test_verify_history_cursor_past_the_end_is_empty_not_500() { + let (status, json) = get_chain("/history?cursor=99&page_size=2", chain_records(5)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["count"], 5); + assert!(json["transactions"].as_array().unwrap().is_empty()); + assert!(json["next_cursor"].is_null()); +} + +#[tokio::test] +async fn test_verify_history_page_size_is_capped_at_the_maximum() { + let (status, json) = get_chain("/history?page_size=100000", chain_records(250)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["count"], 250); + assert_eq!(json["transactions"].as_array().unwrap().len(), 200); + assert_eq!(json["next_cursor"], 200); +}