diff --git a/Cargo.lock b/Cargo.lock index 47d80af..676bfda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,7 +322,7 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -349,7 +349,7 @@ dependencies = [ "serde_with", "smol_str", "stacker", - "thiserror", + "thiserror 2.0.18", "unicode-security", ] @@ -457,7 +457,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -712,7 +712,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", ] @@ -798,7 +798,9 @@ dependencies = [ "forge-types", "hyper", "hyper-util", + "json-patch", "pasetors", + "redbx", "rmp-serde", "serde_json", "tempfile", @@ -831,7 +833,7 @@ version = "0.2.0" dependencies = [ "redbx", "serde", - "thiserror", + "thiserror 2.0.18", "toml", "uuid", ] @@ -1225,6 +1227,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "keccak" version = "0.1.6" @@ -2224,13 +2248,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ae18c46..5c7ad8f 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -23,6 +23,8 @@ uuid = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } tower-http = { workspace = true } +json-patch = "4.1.0" +redbx.workspace = true [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 8623f18..f1dcf74 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -115,17 +115,21 @@ async fn schema_info() -> Result { } /// GET /v1/:collection -/// Lists all documents in a collection. +/// Lists documents in a collection, chunked via cursor-based pagination. /// -/// Transcodes to JSON if requested; otherwise defaults to MessagePack with named fields. -/// Yeah, it's a full scan — pagination and cursors come in v0.3 Phase B. +/// Query params: `cursor` (string), `limit` (integer). +/// Defaults to returning MessagePack. Transcodes to JSON only if requested. async fn list_docs( State(state): State, Path(collection): Path, + axum::extract::Query(params): axum::extract::Query, headers: axum::http::HeaderMap, ) -> Result { - match state.engine.list(&collection) { - Ok(docs) => { + let limit = params.limit.unwrap_or(50).clamp(1, 100) as usize; + let cursor = params.cursor.as_deref(); + + match state.engine.list_paginated(&collection, cursor, limit) { + Ok((docs, next_cursor)) => { let accept = headers .get(axum::http::header::ACCEPT) .and_then(|h| h.to_str().ok()) @@ -140,25 +144,40 @@ async fn list_docs( serde_json::json!({ "id": id, "doc": doc }) }) .collect(); + + let has_more = next_cursor.is_some(); + let response = forge_types::pagination::PaginatedResponse { + data: json_docs, + next_cursor: next_cursor.clone(), + has_more, + }; + Ok(( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], - serde_json::to_vec(&json_docs).unwrap_or_default(), + serde_json::to_vec(&response).unwrap_or_default(), ) .into_response()) } else { - // For MessagePack, we need to wrap the internal documents in a structured array. - // We use serde_json::Value as an intermediate to deserialize the inner doc and - // re-serialize as named msgpack, avoiding strict struct typing. - let mut wrapper = Vec::new(); + // For MessagePack, we deserialize the internal payload, wrap it, + // and pack it into a structured array inside a PaginatedResponse. + let mut wrapper = Vec::with_capacity(docs.len()); for (id, bytes) in docs { if let Ok(val) = rmp_serde::from_slice::(&bytes) { wrapper.push(serde_json::json!({ "id": id, "doc": val })); } } + + let has_more = next_cursor.is_some(); + let response = forge_types::pagination::PaginatedResponse { + data: wrapper, + next_cursor, + has_more, + }; + let resp_bytes = - forge_storage::document::serialize_doc_named(&wrapper).map_err(|e| { - tracing::error!("failed to serialize list to msgpack: {e}"); + forge_storage::document::serialize_doc_named(&response).map_err(|e| { + tracing::error!("failed to serialize paginated list to msgpack: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -171,7 +190,7 @@ async fn list_docs( } } Err(e) => { - tracing::error!("list failed: {e}"); + tracing::error!("list_paginated failed: {e}"); Err(StatusCode::INTERNAL_SERVER_ERROR) } } @@ -323,12 +342,117 @@ async fn query_docs_stub() -> impl IntoResponse { } /// PATCH /v1/:collection/:id -/// Partial update (placeholder). -async fn update_doc() -> impl IntoResponse { - ( - StatusCode::NOT_IMPLEMENTED, - "Atomic updates planned for v0.3", - ) +/// Partial atomic update using RFC 7396 JSON Merge Patch semantics. +/// +/// Converts the inbound patch (JSON or MsgPack) to a generic `Value`, reads the +/// current document, merges the fields atomically, and saves it. +/// Returns the updated document. +async fn update_doc( + State(state): State, + Path((collection, id)): Path<(String, String)>, + headers: axum::http::HeaderMap, + body: bytes::Bytes, +) -> Result { + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + + // Deserialize inbound patch + let patch_val: serde_json::Value = if content_type.contains("application/json") { + serde_json::from_slice(&body).map_err(|e| { + tracing::warn!("failed to parse JSON patch: {e}"); + StatusCode::BAD_REQUEST + })? + } else { + forge_storage::document::deserialize_doc(&body).map_err(|e| { + tracing::warn!("failed to parse MessagePack patch: {e}"); + StatusCode::BAD_REQUEST + })? + }; + + // Serialize it back to bytes for the merge_fn signature (it expects `&[u8]`) + // Actually, we can just close over `patch_val` and ignore the `patch_bytes` param + // to avoid an extra alloc, or pass an empty slice. We'll pass the patch bytes to + // appease the signature, but use our decoded struct. + + // However, the signature is `engine.update_doc(..., patch: &[u8], merge_fn)` + // So let's serialize the patch explicitly for the call. + let patch_bytes = forge_storage::document::serialize_doc(&patch_val).map_err(|e| { + tracing::error!("failed to serialize patch intermediate: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match state.engine.update_doc( + &collection, + &id, + &patch_bytes, + |existing_bytes, _patch_bytes| { + // Read the existing document into a mutable serde_json::Value + let mut doc: serde_json::Value = + forge_storage::document::deserialize_doc(existing_bytes).map_err(|e| { + tracing::error!("corrupted storage doc during update: {e}"); + forge_types::ForgeError::Storage(redbx::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Storage corruption", + ))) + })?; + + // Apply the RFC 7396 Merge Patch + json_patch::merge(&mut doc, &patch_val); + + // Re-encode back to compact internal representation + let final_bytes = forge_storage::document::serialize_doc(&doc).map_err(|e| { + tracing::error!("failed to re-encode merged doc to msgpack: {e}"); + forge_types::ForgeError::Storage(redbx::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Re-encoding failed", + ))) + })?; + + Ok(final_bytes) + }, + ) { + Ok(merged_bytes) => { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + + let val: serde_json::Value = forge_storage::document::deserialize_doc(&merged_bytes) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response_body = serde_json::json!({ "id": id, "doc": val }); + + if accept.contains("application/json") { + let json_bytes = serde_json::to_vec(&response_body).unwrap_or_default(); + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/json")], + json_bytes, + ) + .into_response()) + } else { + let msg_bytes = forge_storage::document::serialize_doc_named(&response_body) + .unwrap_or_default(); + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/msgpack")], + msg_bytes, + ) + .into_response()) + } + } + Err(forge_types::ForgeError::Storage(redbx::Error::Io(e))) + if e.kind() == std::io::ErrorKind::NotFound => + { + Err(StatusCode::NOT_FOUND) + } + Err(e) => { + tracing::error!("update failed: {e}"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } } /// DELETE /v1/:collection/:id diff --git a/crates/server/tests/api_pipeline.rs b/crates/server/tests/api_pipeline.rs index d070b4c..96e9641 100644 --- a/crates/server/tests/api_pipeline.rs +++ b/crates/server/tests/api_pipeline.rs @@ -201,3 +201,125 @@ async fn garbage_bearer_token_returns_401() { "garbage token must be rejected" ); } + +#[tokio::test] +async fn paginate_documents() { + let (app, token, _tmp) = test_harness(); + + // Insert 3 documents + for i in 1..=3 { + let req = Request::builder() + .method("POST") + .uri("/v1/items") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!(r#"{{"idx":{i}}}"#))) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + } + + // Fetch page 1 (limit=2) + let req1 = Request::builder() + .uri("/v1/items?limit=2") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .body(Body::empty()) + .unwrap(); + + let resp1 = app.clone().oneshot(req1).await.unwrap(); + assert_eq!(resp1.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(resp1.into_body(), 4096).await.unwrap(); + let body: forge_types::pagination::PaginatedResponse = + serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(body.data.len(), 2); + assert!(body.has_more); + let next_cursor = body.next_cursor.expect("must have cursor"); + + // Fetch page 2 (limit=2, cursor=next_cursor) + let req2 = Request::builder() + .uri(format!("/v1/items?limit=2&cursor={next_cursor}")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .body(Body::empty()) + .unwrap(); + + let resp2 = app.clone().oneshot(req2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(resp2.into_body(), 4096).await.unwrap(); + let body: forge_types::pagination::PaginatedResponse = + serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(body.data.len(), 1); + assert!(!body.has_more); + assert_eq!(body.next_cursor, None); +} + +#[tokio::test] +async fn patch_document() { + let (app, token, _tmp) = test_harness(); + + // 1. Insert original + let insert_req = Request::builder() + .method("POST") + .uri("/v1/items") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCEPT, "application/json") + .body(Body::from(r#"{"name":"test","value":42,"keep":"this"}"#)) + .unwrap(); + + let insert_resp = app.clone().oneshot(insert_req).await.unwrap(); + assert_eq!(insert_resp.status(), StatusCode::CREATED); + + let body_bytes = axum::body::to_bytes(insert_resp.into_body(), 4096) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + let doc_id = body["id"].as_str().expect("id required"); + + // 2. Patch document (update value, delete name, add new_field) + let patch_req = Request::builder() + .method("PATCH") + .uri(format!("/v1/items/{doc_id}")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCEPT, "application/json") + .body(Body::from( + r#"{"value":99,"name":null,"new_field":"added"}"#, + )) + .unwrap(); + + let patch_resp = app.clone().oneshot(patch_req).await.unwrap(); + assert_eq!(patch_resp.status(), StatusCode::OK); + + // 3. Verify changes were applied completely + let get_req = Request::builder() + .uri(format!("/v1/items/{doc_id}")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .body(Body::empty()) + .unwrap(); + + let get_resp = app.oneshot(get_req).await.unwrap(); + assert_eq!(get_resp.status(), StatusCode::OK); + + let final_bytes = axum::body::to_bytes(get_resp.into_body(), 4096) + .await + .unwrap(); + + let final_body: serde_json::Value = serde_json::from_slice(&final_bytes).unwrap(); + println!("FINAL BODY: {}", final_body); + + let doc = &final_body; + assert_eq!(doc["value"], 99); + assert_eq!(doc["keep"], "this"); + assert_eq!(doc["new_field"], "added"); + assert!( + doc.get("name").is_none(), + "name should be deleted (null merge semantics)" + ); +} diff --git a/crates/storage/src/engine.rs b/crates/storage/src/engine.rs index 7987426..cf032ad 100644 --- a/crates/storage/src/engine.rs +++ b/crates/storage/src/engine.rs @@ -59,17 +59,6 @@ pub struct StorageEngine { db: Database, } -impl StorageEngine { - /// Crate-internal handle to the raw redbx `Database`. - /// - /// The write batcher needs this so it can open a single transaction that - /// spans every document in a coalesced batch — way cheaper than N - /// individual `insert()` calls each with their own fsync. - pub(crate) fn raw_db(&self) -> &Database { - &self.db - } -} - impl StorageEngine { /// Create a new encrypted database at `path` with default storage tuning. /// @@ -268,6 +257,119 @@ impl StorageEngine { } Ok(results) } +} + +/// A page of documents returned from `list_paginated`. +/// Contains the items and an optional cursor for the next page. +pub type PaginatedList = (Vec<(String, Bytes)>, Option); + +impl StorageEngine { + /// List documents with cursor-based pagination. + /// + /// Fetches up to `limit + 1` records starting *after* the provided `cursor`. + /// The `+ 1` trick allows us to detect if there's a subsequent page without + /// running a separate (and potentially costly) count query. + /// + /// # Errors + /// Returns [`ForgeError::Storage`] if the read transaction fails. + pub fn list_paginated( + &self, + collection: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let table_def: TableDefinition<&str, &[u8]> = TableDefinition::new(collection); + let txn = self.db.begin_read().map_err(redbx::Error::from)?; + + let table = match txn.open_table(table_def) { + Ok(t) => t, + Err(redbx::TableError::TableDoesNotExist(_)) => return Ok((Vec::new(), None)), + Err(e) => return Err(ForgeError::Storage(e.into())), + }; + + let mut results: Vec<(String, Bytes)> = Vec::with_capacity(limit + 1); + let mut next_cursor = None; + + if let Some(c) = cursor { + // True keyset pagination: ask the B-Tree to start strictly AFTER the cursor + let iter = table + .range::<&str>((std::ops::Bound::Excluded(c), std::ops::Bound::Unbounded)) + .map_err(redbx::Error::from)?; + for entry in iter { + let (key, value) = entry.map_err(redbx::Error::from)?; + let id = key.value().to_string(); + + results.push((id, Bytes::copy_from_slice(value.value()))); + if results.len() > limit { + break; + } + } + } else { + let iter = table.iter().map_err(redbx::Error::from)?; + for entry in iter { + let (key, value) = entry.map_err(redbx::Error::from)?; + let id = key.value().to_string(); + + results.push((id, Bytes::copy_from_slice(value.value()))); + if results.len() > limit { + break; + } + } + } + + if results.len() > limit { + // We got one more than asked for, which means there is a next page. + // The cursor for the next page is the ID of the last item *on this page*. + results.pop(); // Remove that extra item so we only return `limit` items + next_cursor = results.last().map(|(k, _)| k.clone()); + } + + Ok((results, next_cursor)) + } + + /// Read-modify-write a document atomically within a single transaction. + /// + /// The `merge_fn` receives `(existing_bytes, patch_bytes)` and returns the merged bytes. + /// This separation keeps the storage layer entirely codec-agnostic while ensuring + /// the fetch and write happen without intervening mutations. + /// + /// # Errors + /// Returns [`ForgeError::Storage`] if the document doesn't exist, if the read/write + /// fails, or if `merge_fn` returns an error. + pub fn update_doc( + &self, + collection: &str, + id: &str, + patch: &[u8], + merge_fn: impl Fn(&[u8], &[u8]) -> Result>, + ) -> Result> { + let table_def: TableDefinition<&str, &[u8]> = TableDefinition::new(collection); + let txn = self.db.begin_write().map_err(redbx::Error::from)?; + + let merged = { + let table = txn.open_table(table_def).map_err(redbx::Error::from)?; + + let existing = table.get(id).map_err(redbx::Error::from)?.ok_or_else(|| { + ForgeError::Storage(redbx::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("document '{id}' not found for patch"), + ))) + })?; + + merge_fn(existing.value(), patch)? + }; + + // We re-open the table block to bypass borrow-checker holding `existing` + { + let mut table = txn.open_table(table_def).map_err(redbx::Error::from)?; + table + .insert(id, merged.as_slice()) + .map_err(redbx::Error::from)?; + } + + txn.commit().map_err(redbx::Error::from)?; + Ok(merged) + } /// Exposes a safe handle for appending entries to the immutable audit log. pub fn audit_log(&self) -> crate::audit::AuditLog<'_> { @@ -333,6 +435,72 @@ mod tests { assert!(engine.list("ghost").unwrap().is_empty()); } + #[test] + fn list_paginated() { + let (engine, _tmp) = test_engine(); + // Insert 5 items + for i in 1..=5 { + engine + .insert("items", &format!("k{i}"), format!("v{i}").as_bytes()) + .unwrap(); + } + + // Page 1: limits to 2, returns cursor "k2" + let (p1, cursor1) = engine.list_paginated("items", None, 2).unwrap(); + assert_eq!(p1.len(), 2); + assert_eq!(p1[0].0, "k1"); + assert_eq!(p1[1].0, "k2"); + assert_eq!(cursor1.as_deref(), Some("k2")); + + // Page 2: starts after "k2", limits to 2, returns cursor "k4" + let (p2, cursor2) = engine + .list_paginated("items", cursor1.as_deref(), 2) + .unwrap(); + assert_eq!(p2.len(), 2); + assert_eq!(p2[0].0, "k3"); + assert_eq!(p2[1].0, "k4"); + assert_eq!(cursor2.as_deref(), Some("k4")); + + // Page 3: starts after "k4", limits to 2, gets 1, cursor None + let (p3, cursor3) = engine + .list_paginated("items", cursor2.as_deref(), 2) + .unwrap(); + assert_eq!(p3.len(), 1); + assert_eq!(p3[0].0, "k5"); + assert_eq!(cursor3, None); + + // Page 4: empty + let (p4, cursor4) = engine.list_paginated("items", Some("k5"), 2).unwrap(); + assert!(p4.is_empty()); + assert_eq!(cursor4, None); + } + + #[test] + fn update_doc_atomic_patch() { + let (engine, _tmp) = test_engine(); + engine.insert("users", "u1", b"original").unwrap(); + + let patched = engine + .update_doc("users", "u1", b"patched", |old, new| { + assert_eq!(old, b"original"); + Ok(new.to_vec()) + }) + .unwrap(); + + assert_eq!(patched, b"patched"); + + // Verify the write was actually persisted + let verify = engine.get("users", "u1").unwrap(); + assert_eq!(verify.unwrap(), b"patched".as_slice()); + } + + #[test] + fn update_doc_returns_not_found() { + let (engine, _tmp) = test_engine(); + let res = engine.update_doc("ghosts", "g1", b"boo", |_, new| Ok(new.to_vec())); + assert!(matches!(res, Err(ForgeError::Storage(_)))); + } + #[test] fn collections_are_isolated() { let (engine, _tmp) = test_engine(); diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 7e3cf08..5ca69af 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -7,10 +7,12 @@ pub mod audit; pub mod config; pub mod error; +pub mod pagination; pub use audit::{AuditEntry, Outcome}; pub use config::ForgeConfig; pub use error::ForgeError; +pub use pagination::{PaginatedResponse, PaginationParams}; /// Shorthand for `std::result::Result`. pub type Result = std::result::Result; diff --git a/crates/types/src/pagination.rs b/crates/types/src/pagination.rs new file mode 100644 index 0000000..4949889 --- /dev/null +++ b/crates/types/src/pagination.rs @@ -0,0 +1,77 @@ +//! Common types for cursor-based pagination. +//! +//! We use cursor-based pagination (keyset pagination) instead of offset/limit +//! because offset forces the storage engine to scan and discard rows, which scales +//! terribly for deep pages. Cursors leverage the B-tree directly. + +use serde::{Deserialize, Serialize}; + +/// Pagination parameters typically passed via query string. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaginationParams { + /// The document ID to start _after_. If absent, starts from the beginning. + pub cursor: Option, + /// Maximum number of items to return. Defaults to 50 if missing. + pub limit: Option, +} + +impl Default for PaginationParams { + fn default() -> Self { + Self { + cursor: None, + limit: Some(50), + } + } +} + +impl PaginationParams { + /// Resolves the requested limit, enforcing bounds. + /// + /// The default is 50. The hard ceiling is 1000 to prevent runaway + /// memory allocation and massive API payloads. + #[must_use] + pub fn resolved_limit(&self) -> usize { + self.limit.unwrap_or(50).clamp(1, 1000) as usize + } +} + +/// A standard paginated response envelope. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaginatedResponse { + /// The actual items for this page. + pub data: Vec, + /// The cursor to pass in the next request to get the next page. + /// Will be `None` if `has_more` is false. + pub next_cursor: Option, + /// Whether there are more items beyond this page. + pub has_more: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limit_resolution_bounds() { + let empty = PaginationParams::default(); + assert_eq!(empty.resolved_limit(), 50); + + let over = PaginationParams { + cursor: None, + limit: Some(5000), + }; + assert_eq!(over.resolved_limit(), 1000); + + let under = PaginationParams { + cursor: None, + limit: Some(0), + }; + assert_eq!(under.resolved_limit(), 1); + + let fine = PaginationParams { + cursor: None, + limit: Some(150), + }; + assert_eq!(fine.resolved_limit(), 150); + } +}