This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 7b0222f1e feat(rest): support configurable page sizes (#3295)
7b0222f1e is described below
commit 7b0222f1e87e29273bd7bd98306e5c99fe0dfea8
Author: Paul Roman <[email protected]>
AuthorDate: Mon Oct 26 18:31:03 2026 +0000
feat(rest): support configurable page sizes (#3295)
* feat(rest): support configurable page sizes
- Configure namespace and table page sizes through builders or properties.
- Honor configuration precedence and validate only the effective page size.
- Opt into pagination while preserving complete listing results.
* test(rest): cover empty pages and invalid client page sizes
- Verify listings continue through empty pages with continuation tokens.
- Verify invalid client page sizes do not fall back to server defaults.
* test(rest): verify page-size interoperability with the REST fixture
- Exercise namespace and table listings with a configured page size.
- Verify complete, duplicate-free results against the live catalog fixture.
* refactor(rest): simplify catalog config
Co-authored-by: Marco Kroll <[email protected]>
* refactor(rest): rename a test
* fix(rest): bound page sizes for Java interoperability
- Reject effective page sizes above i32::MAX after configuration merging.
- Include the offending value in page-size validation errors.
- Update documentation and existing boundary and precedence tests.
* refactor(rest): simplify page-size error construction
- Reuse the invalid page-size error constructor for parsing and range
checks.
- Preserve parse-error sources and explicit upper-bound validation.
* refactor(rest): take NonZeroU32 in with_page_size
Make a zero page size unrepresentable in the builder setters.
Co-authored-by: Copilot App <[email protected]>
* docs(rest): document page-size precedence and validation once
Keep the contract on RestSessionCatalogBuilder::with_page_size and link to
it.
Co-authored-by: Copilot App <[email protected]>
---------
Co-authored-by: Marco Kroll <[email protected]>
Co-authored-by: Kevin Liu <[email protected]>
Co-authored-by: Kevin Liu <[email protected]>
Co-authored-by: Copilot App <[email protected]>
---
.../catalog/loader/tests/rest_pagination_test.rs | 94 ++++++
crates/catalog/rest/public-api.txt | 3 +
crates/catalog/rest/src/catalog.rs | 353 ++++++++++++++++++++-
crates/catalog/rest/src/lib.rs | 5 +
4 files changed, 449 insertions(+), 6 deletions(-)
diff --git a/crates/catalog/loader/tests/rest_pagination_test.rs
b/crates/catalog/loader/tests/rest_pagination_test.rs
new file mode 100644
index 000000000..bf1889c35
--- /dev/null
+++ b/crates/catalog/loader/tests/rest_pagination_test.rs
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! REST pagination against the Docker fixture started by `make docker-up`.
+
+mod common;
+
+use std::collections::{HashMap, HashSet};
+use std::num::NonZeroU32;
+use std::sync::Arc;
+
+use common::{cleanup_namespace_dyn, table_creation};
+use iceberg::io::LocalFsStorageFactory;
+use iceberg::{Catalog, CatalogBuilder, NamespaceIdent, Result};
+use iceberg_catalog_rest::{REST_CATALOG_PROP_URI, RestCatalogBuilder};
+use iceberg_test_utils::{get_rest_catalog_endpoint,
normalize_test_name_with_parts, set_up};
+
+#[tokio::test]
+async fn test_rest_lists_all_namespaces_and_tables_with_page_size() ->
Result<()> {
+ set_up();
+ let catalog = RestCatalogBuilder::default()
+ .with_storage_factory(Arc::new(LocalFsStorageFactory))
+ .with_page_size(NonZeroU32::MIN)
+ .load(
+ "rest",
+ HashMap::from([(
+ REST_CATALOG_PROP_URI.to_string(),
+ get_rest_catalog_endpoint(),
+ )]),
+ )
+ .await?;
+ let parent_name = normalize_test_name_with_parts!("pagination");
+ let parent = NamespaceIdent::new(parent_name.clone());
+ let expected_namespaces = ["child1", "child2", "child3"]
+ .into_iter()
+ .map(|name| NamespaceIdent::from_strs([parent_name.as_str(), name]))
+ .collect::<Result<Vec<_>>>()?;
+
+ // Isolate the listing from other suites and clean up remnants of failed
runs.
+ for child in &expected_namespaces {
+ cleanup_namespace_dyn(&catalog, child).await;
+ }
+ cleanup_namespace_dyn(&catalog, &parent).await;
+ catalog.create_namespace(&parent, HashMap::new()).await?;
+ for child in &expected_namespaces {
+ catalog.create_namespace(child, HashMap::new()).await?;
+ }
+ let mut expected_tables = Vec::new();
+ for name in ["table1", "table2", "table3"] {
+ let table = catalog.create_table(&parent, table_creation(name)).await?;
+ expected_tables.push(table.identifier().clone());
+ }
+
+ let namespaces = catalog.list_namespaces(Some(&parent)).await;
+ let tables = catalog.list_tables(&parent).await;
+
+ // Clean up before assertions so a pagination regression does not leave
data behind.
+ for table in &expected_tables {
+ catalog.purge_table(table).await?;
+ }
+ for child in &expected_namespaces {
+ catalog.drop_namespace(child).await?;
+ }
+ catalog.drop_namespace(&parent).await?;
+
+ let namespaces = namespaces?;
+ let tables = tables?;
+
+ assert_eq!(namespaces.len(), expected_namespaces.len());
+ assert_eq!(tables.len(), expected_tables.len());
+ assert_eq!(
+ namespaces.into_iter().collect::<HashSet<_>>(),
+ expected_namespaces.into_iter().collect::<HashSet<_>>()
+ );
+ assert_eq!(
+ tables.into_iter().collect::<HashSet<_>>(),
+ expected_tables.into_iter().collect::<HashSet<_>>()
+ );
+ Ok(())
+}
diff --git a/crates/catalog/rest/public-api.txt
b/crates/catalog/rest/public-api.txt
index ff080def7..a0c6fd03c 100644
--- a/crates/catalog/rest/public-api.txt
+++ b/crates/catalog/rest/public-api.txt
@@ -302,6 +302,7 @@ pub struct iceberg_catalog_rest::RestCatalogBuilder
impl iceberg_catalog_rest::RestCatalogBuilder
pub fn iceberg_catalog_rest::RestCatalogBuilder::with_auth_manager<M>(self,
auth_manager: M) -> Self where M: iceberg_catalog_rest::AuthManager + 'static
pub fn iceberg_catalog_rest::RestCatalogBuilder::with_client(self, client:
reqwest::async_impl::client::Client) -> Self
+pub fn iceberg_catalog_rest::RestCatalogBuilder::with_page_size(self,
page_size: core::num::nonzero::NonZeroU32) -> Self
pub fn iceberg_catalog_rest::RestCatalogBuilder::with_session_context(self,
context: iceberg::catalog::session::SessionContext) -> Self
impl core::default::Default for iceberg_catalog_rest::RestCatalogBuilder
pub fn iceberg_catalog_rest::RestCatalogBuilder::default() ->
iceberg_catalog_rest::RestCatalogBuilder
@@ -338,6 +339,7 @@ pub fn
iceberg_catalog_rest::RestSessionCatalogBuilder::load(self, name: impl co
pub fn
iceberg_catalog_rest::RestSessionCatalogBuilder::with_auth_manager<M>(self,
auth_manager: M) -> Self where M: iceberg_catalog_rest::AuthManager + 'static
pub fn iceberg_catalog_rest::RestSessionCatalogBuilder::with_client(self,
client: reqwest::async_impl::client::Client) -> Self
pub fn
iceberg_catalog_rest::RestSessionCatalogBuilder::with_kms_client_factory(self,
kms_client_factory: alloc::sync::Arc<dyn
iceberg::encryption::kms::factory::KmsClientFactory>) -> Self
+pub fn iceberg_catalog_rest::RestSessionCatalogBuilder::with_page_size(self,
page_size: core::num::nonzero::NonZeroU32) -> Self
pub fn iceberg_catalog_rest::RestSessionCatalogBuilder::with_runtime(self,
runtime: iceberg::runtime::Runtime) -> Self
pub fn
iceberg_catalog_rest::RestSessionCatalogBuilder::with_storage_factory(self,
storage_factory: alloc::sync::Arc<dyn iceberg::io::storage::StorageFactory>) ->
Self
impl core::default::Default for iceberg_catalog_rest::RestSessionCatalogBuilder
@@ -403,6 +405,7 @@ pub const iceberg_catalog_rest::AUTH_TYPE_NONE: &str
pub const iceberg_catalog_rest::AUTH_TYPE_OAUTH2: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_AUTH_TYPE: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_DISABLE_HEADER_REDACTION:
&str
+pub const iceberg_catalog_rest::REST_CATALOG_PROP_PAGE_SIZE: &str
pub const
iceberg_catalog_rest::REST_CATALOG_PROP_TABLE_CACHE_EXPIRE_AFTER_WRITE_MS: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_TABLE_CACHE_MAX_ENTRIES: &str
pub const iceberg_catalog_rest::REST_CATALOG_PROP_URI: &str
diff --git a/crates/catalog/rest/src/catalog.rs
b/crates/catalog/rest/src/catalog.rs
index b39869df4..8b8ed983d 100644
--- a/crates/catalog/rest/src/catalog.rs
+++ b/crates/catalog/rest/src/catalog.rs
@@ -20,6 +20,7 @@
use std::collections::{HashMap, HashSet};
use std::fmt::{Debug, Formatter};
use std::future::Future;
+use std::num::NonZeroU32;
use std::str::FromStr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -61,6 +62,9 @@ use crate::types::{
pub const REST_CATALOG_PROP_URI: &str = "uri";
/// REST catalog warehouse location
pub const REST_CATALOG_PROP_WAREHOUSE: &str = "warehouse";
+/// Requested page size for namespace and table listings; see
+/// [`RestSessionCatalogBuilder::with_page_size`].
+pub const REST_CATALOG_PROP_PAGE_SIZE: &str = "rest-page-size";
/// Disable header redaction in error logs and `Debug` output (defaults to
/// false for security)
pub const REST_CATALOG_PROP_DISABLE_HEADER_REDACTION: &str =
"disable-header-redaction";
@@ -144,6 +148,13 @@ impl CatalogBuilder for RestCatalogBuilder {
}
impl RestCatalogBuilder {
+ /// Sets the requested page size for namespace and table listings; see
+ /// [`RestSessionCatalogBuilder::with_page_size`].
+ pub fn with_page_size(mut self, page_size: NonZeroU32) -> Self {
+ self.inner = self.inner.with_page_size(page_size);
+ self
+ }
+
/// Configures the catalog with a custom HTTP client.
pub fn with_client(mut self, client: Client) -> Self {
self.inner = self.inner.with_client(client);
@@ -254,6 +265,28 @@ impl RestCatalogConfig {
self.props.get("oauth2-server-uri").cloned()
}
+ /// Parse only the effective value, after server defaults and overrides
are merged.
+ fn page_size(&self) -> Result<Option<NonZeroU32>> {
+ self.props
+ .get(REST_CATALOG_PROP_PAGE_SIZE)
+ .map(|value| {
+ let invalid_page_size = || {
+ Error::new(
+ ErrorKind::DataInvalid,
+ format!("Invalid {REST_CATALOG_PROP_PAGE_SIZE}:
{value}"),
+ )
+ };
+ let page_size = value
+ .parse::<NonZeroU32>()
+ .map_err(|err| invalid_page_size().with_source(err))?;
+ if page_size.get() > i32::MAX as u32 {
+ return Err(invalid_page_size());
+ }
+ Ok(page_size)
+ })
+ .transpose()
+ }
+
fn namespaces_endpoint(&self) -> String {
self.url_prefixed(&["namespaces"])
}
@@ -498,6 +531,8 @@ struct RestCatalogClient {
config: RestCatalogConfig,
/// Capabilities the server advertises (see
[`RestSessionCatalog::supports_endpoint`]).
endpoints: HashSet<Endpoint>,
+ /// Validated page size from the merged runtime configuration.
+ page_size: Option<NonZeroU32>,
/// Tables reused when the server answers a load with `304 Not Modified`.
table_cache: Option<TableCache>,
}
@@ -538,6 +573,7 @@ impl RestCatalogClient {
_ => crate::endpoint::DEFAULT_ENDPOINTS.clone(),
};
let config = user_config.clone().merge_with_config(catalog_config);
+ let page_size = config.page_size()?;
let http_client = http_client.update_with(&config)?;
// The manager is handed an unauthenticated client: its own
// requests must not be signed by the session it is deriving.
@@ -552,6 +588,7 @@ impl RestCatalogClient {
config,
http_client,
endpoints,
+ page_size,
})
}
@@ -1000,11 +1037,16 @@ impl SessionCatalog for RestSessionCatalog {
let client = self.client().await?;
let endpoint = client.config.namespaces_endpoint();
let mut namespaces = Vec::new();
- let mut next_token = None;
+ // An empty initial token explicitly opts in to pagination.
+ let mut next_token = client.page_size.map(|_| String::new());
loop {
let mut request = client.http_client.request(Method::GET,
endpoint.clone());
+ if let Some(page_size) = client.page_size {
+ request = request.query(&[("pageSize", page_size)]);
+ }
+
// Filter on `parent={namespace}` if a parent namespace exists.
if let Some(ns) = parent {
request = request.query(&[("parent", ns.to_url_string())]);
@@ -1185,11 +1227,16 @@ impl SessionCatalog for RestSessionCatalog {
let client = self.client().await?;
let endpoint = client.config.tables_endpoint(namespace);
let mut identifiers = Vec::new();
- let mut next_token = None;
+ // An empty initial token explicitly opts in to pagination.
+ let mut next_token = client.page_size.map(|_| String::new());
loop {
let mut request = client.http_client.request(Method::GET,
endpoint.clone());
+ if let Some(page_size) = client.page_size {
+ request = request.query(&[("pageSize", page_size)]);
+ }
+
if let Some(token) = next_token {
request = request.query(&[("pageToken", token)]);
}
@@ -1672,6 +1719,21 @@ impl Default for RestSessionCatalogBuilder {
}
impl RestSessionCatalogBuilder {
+ /// Sets the requested page size for namespace and table listings.
+ ///
+ /// List operations still return all results. Without a page size, no
+ /// `pageSize` is sent. Precedence, lowest to highest: server defaults,
this
+ /// value, [`REST_CATALOG_PROP_PAGE_SIZE`] passed to [`load`](Self::load),
and
+ /// server overrides. Catalog operations fail with
[`ErrorKind::DataInvalid`]
+ /// if the effective value is not in `1..=i32::MAX`.
+ pub fn with_page_size(mut self, page_size: NonZeroU32) -> Self {
+ self.config.props.insert(
+ REST_CATALOG_PROP_PAGE_SIZE.to_string(),
+ page_size.to_string(),
+ );
+ self
+ }
+
/// Configures the catalog with a custom HTTP client.
pub fn with_client(mut self, client: Client) -> Self {
self.config.client = Some(client);
@@ -1778,10 +1840,9 @@ impl RestSessionCatalogBuilder {
}
// Collect other remaining properties
- self.config.props = props
- .into_iter()
- .filter(|(k, _)| k != REST_CATALOG_PROP_URI && k !=
REST_CATALOG_PROP_WAREHOUSE)
- .collect();
+ self.config.props.extend(props);
+ self.config.props.remove(REST_CATALOG_PROP_URI);
+ self.config.props.remove(REST_CATALOG_PROP_WAREHOUSE);
async move {
if self.config.name.is_none() {
@@ -1954,6 +2015,286 @@ mod tests {
)
}
+ #[tokio::test]
+ async fn test_page_size_precedence() {
+ // Server default < builder < load properties < server override.
+ for (default, builder, property, override_value, expected) in [
+ (Some("10"), None, None, None, 10),
+ (Some("10"), Some(20), None, None, 20),
+ (None, Some(20), None, None, 20),
+ (Some("10"), None, Some("30"), None, 30),
+ (Some("10"), Some(20), Some("30"), None, 30),
+ (Some("10"), Some(20), Some("30"), Some("40"), 40),
+ (None, None, None, Some("40"), 40),
+ (None, Some(1), None, None, 1),
+ (None, Some(i32::MAX as u32), None, None, i32::MAX as u32),
+ // Invalid lower-priority values must not reject a valid effective
value.
+ (Some("invalid"), Some(20), None, None, 20),
+ (None, Some(20), Some("0"), Some("40"), 40),
+ (None, Some(u32::MAX), None, Some("40"), 40),
+ ] {
+ let mut server = Server::new_async().await;
+ let page_size_props = |value: Option<&str>| -> HashMap<String,
String> {
+ value
+ .map(|value| (REST_CATALOG_PROP_PAGE_SIZE.to_string(),
value.to_string()))
+ .into_iter()
+ .collect()
+ };
+ let config_mock = server
+ .mock("GET", "/v1/config")
+ .with_status(200)
+ .with_body(
+ json!({
+ "defaults": page_size_props(default),
+ "overrides": page_size_props(override_value),
+ })
+ .to_string(),
+ )
+ .create_async()
+ .await;
+ let list_mock = server
+ .mock("GET", "/v1/namespaces")
+ .match_query(Matcher::UrlEncoded("pageSize".into(),
expected.to_string()))
+ .with_status(200)
+ .with_body(r#"{"namespaces": []}"#)
+ .expect(1)
+ .create_async()
+ .await;
+
+ let mut builder_config = RestCatalogBuilder::default();
+ if let Some(page_size) = builder {
+ builder_config =
builder_config.with_page_size(NonZeroU32::new(page_size).unwrap());
+ }
+ let mut props = page_size_props(property);
+ props.insert(REST_CATALOG_PROP_URI.to_string(), server.url());
+ let catalog = builder_config.load("test", props).await.unwrap();
+
+ catalog.list_namespaces(None).await.unwrap_or_else(|err| {
+ panic!(
+ "default={default:?}, builder={builder:?},
property={property:?}, \
+ override={override_value:?}, expected={expected}: {err}"
+ )
+ });
+ config_mock.assert_async().await;
+ list_mock.assert_async().await;
+ }
+ }
+
+ #[tokio::test]
+ async fn test_page_size_pagination() {
+ let mut server = Server::new_async().await;
+ let config_mock = create_config_mock(&mut server).await;
+ let catalog = RestCatalogBuilder::default()
+ .with_page_size(NonZeroU32::MIN)
+ .load(
+ "test",
+ HashMap::from([(REST_CATALOG_PROP_URI.to_string(),
server.url())]),
+ )
+ .await
+ .unwrap();
+
+ let mut mocks = Vec::new();
+ for (endpoint, parent, first_body, empty_body, last_body) in [
+ (
+ "/v1/namespaces",
+ Some("parent\u{1f}child"),
+ json!({"namespaces": [["ns1"]], "next-page-token": "next/+"}),
+ json!({"namespaces": [], "next-page-token": "last"}),
+ json!({"namespaces": [["ns2"]], "next-page-token": null}),
+ ),
+ (
+ "/v1/namespaces/ns1/tables",
+ None,
+ json!({"identifiers": [{"namespace": ["ns1"], "name": "t1"}],
"next-page-token": "next/+"}),
+ json!({"identifiers": [], "next-page-token": "last"}),
+ json!({"identifiers": [{"namespace": ["ns1"], "name": "t2"}]}),
+ ),
+ ] {
+ // Page 1 opts in with an empty token. Later tokens and the
multipart
+ // parent must survive URL encoding, independent of query
parameter order.
+ // The empty middle page must not terminate pagination.
+ for (token, body) in [
+ ("", first_body),
+ ("next/+", empty_body),
+ ("last", last_body),
+ ] {
+ let mut query = vec![
+ Matcher::UrlEncoded("pageSize".into(), "1".into()),
+ Matcher::UrlEncoded("pageToken".into(), token.into()),
+ ];
+ if let Some(parent) = parent {
+ query.push(Matcher::UrlEncoded("parent".into(),
parent.into()));
+ }
+ mocks.push(
+ server
+ .mock("GET", endpoint)
+ .match_query(Matcher::AllOf(query))
+ .with_status(200)
+ .with_body(body.to_string())
+ .expect(1)
+ .create_async()
+ .await,
+ );
+ }
+ }
+
+ let parent = NamespaceIdent::from_vec(vec!["parent".into(),
"child".into()]).unwrap();
+ assert_eq!(catalog.list_namespaces(Some(&parent)).await.unwrap(), vec![
+ NamespaceIdent::new("ns1".into()),
+ NamespaceIdent::new("ns2".into())
+ ]);
+ let namespace = NamespaceIdent::new("ns1".into());
+ assert_eq!(catalog.list_tables(&namespace).await.unwrap(), vec![
+ TableIdent::new(namespace.clone(), "t1".into()),
+ TableIdent::new(namespace, "t2".into())
+ ]);
+ config_mock.assert_async().await;
+ for mock in mocks {
+ mock.assert_async().await;
+ }
+ }
+
+ #[tokio::test]
+ async fn test_page_size_is_not_a_result_limit() {
+ let mut server = Server::new_async().await;
+ let config_mock = create_config_mock(&mut server).await;
+ let catalog = RestCatalogBuilder::default()
+ .with_page_size(NonZeroU32::MIN)
+ .load(
+ "test",
+ HashMap::from([(REST_CATALOG_PROP_URI.to_string(),
server.url())]),
+ )
+ .await
+ .unwrap();
+
+ // A server may ignore pageSize and return all results without a next
token.
+ let mut mocks = Vec::new();
+ for (endpoint, body) in [
+ ("/v1/namespaces", json!({"namespaces": [["ns1"], ["ns2"]]})),
+ (
+ "/v1/namespaces/ns1/tables",
+ json!({"identifiers": [
+ {"namespace": ["ns1"], "name": "t1"},
+ {"namespace": ["ns1"], "name": "t2"},
+ ]}),
+ ),
+ ] {
+ mocks.push(
+ server
+ .mock("GET", endpoint)
+ .match_query(Matcher::AllOf(vec![
+ Matcher::UrlEncoded("pageSize".into(), "1".into()),
+ Matcher::UrlEncoded("pageToken".into(), "".into()),
+ ]))
+ .with_status(200)
+ .with_body(body.to_string())
+ .expect(1)
+ .create_async()
+ .await,
+ );
+ }
+
+ assert_eq!(catalog.list_namespaces(None).await.unwrap(), vec![
+ NamespaceIdent::new("ns1".into()),
+ NamespaceIdent::new("ns2".into())
+ ]);
+ let namespace = NamespaceIdent::new("ns1".into());
+ assert_eq!(catalog.list_tables(&namespace).await.unwrap(), vec![
+ TableIdent::new(namespace.clone(), "t1".into()),
+ TableIdent::new(namespace, "t2".into())
+ ]);
+ config_mock.assert_async().await;
+ for mock in mocks {
+ mock.assert_async().await;
+ }
+ }
+
+ #[tokio::test]
+ async fn test_page_size_absent() {
+ let mut server = Server::new_async().await;
+ let config_mock = create_config_mock(&mut server).await;
+ let namespaces_mock = server
+ .mock("GET", "/v1/namespaces")
+ .match_query(Matcher::Missing)
+ .with_status(200)
+ .with_body(r#"{"namespaces": []}"#)
+ .create_async()
+ .await;
+ let tables_mock = server
+ .mock("GET", "/v1/namespaces/ns1/tables")
+ .match_query(Matcher::Missing)
+ .with_status(200)
+ .with_body(r#"{"identifiers": []}"#)
+ .create_async()
+ .await;
+ let catalog = RestCatalogBuilder::default()
+ .load(
+ "test",
+ HashMap::from([(REST_CATALOG_PROP_URI.to_string(),
server.url())]),
+ )
+ .await
+ .unwrap();
+
+ assert!(catalog.list_namespaces(None).await.unwrap().is_empty());
+ assert!(
+ catalog
+ .list_tables(&NamespaceIdent::new("ns1".into()))
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ config_mock.assert_async().await;
+ namespaces_mock.assert_async().await;
+ tables_mock.assert_async().await;
+ }
+
+ #[tokio::test]
+ async fn test_invalid_page_size() {
+ for value in [
+ "",
+ "0",
+ "-1",
+ "abc",
+ "1.5",
+ "2147483648",
+ "4294967295",
+ "4294967296",
+ ] {
+ for source in ["defaults", "client", "overrides"] {
+ let mut server = Server::new_async().await;
+ let mut config = json!({"defaults": {}, "overrides": {}});
+ let mut props =
HashMap::from([(REST_CATALOG_PROP_URI.to_string(), server.url())]);
+ if source == "client" {
+ // Invalid client values must not fall back to a valid
server default.
+ config["defaults"][REST_CATALOG_PROP_PAGE_SIZE] =
json!("1000");
+ props.insert(REST_CATALOG_PROP_PAGE_SIZE.to_string(),
value.to_string());
+ } else {
+ config[source][REST_CATALOG_PROP_PAGE_SIZE] = json!(value);
+ }
+ let config_mock = server
+ .mock("GET", "/v1/config")
+ .with_status(200)
+ .with_body(config.to_string())
+ .create_async()
+ .await;
+ let catalog = RestSessionCatalogBuilder::default()
+ .load("test", props)
+ .await
+ .unwrap();
+ let err = catalog
+ .list_namespaces(&SessionContext::empty(), None)
+ .await
+ .unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+ assert_eq!(
+ err.message(),
+ format!("Invalid {REST_CATALOG_PROP_PAGE_SIZE}: {value}")
+ );
+ config_mock.assert_async().await;
+ }
+ }
+ }
+
#[tokio::test]
async fn test_update_config() {
let mut server = Server::new_async().await;
diff --git a/crates/catalog/rest/src/lib.rs b/crates/catalog/rest/src/lib.rs
index 29a171de6..8390a8282 100644
--- a/crates/catalog/rest/src/lib.rs
+++ b/crates/catalog/rest/src/lib.rs
@@ -55,6 +55,11 @@
//! }
//! ```
//!
+//! # Pagination
+//!
+//! Set a page size for namespace and table listings with
+//! [`RestSessionCatalogBuilder::with_page_size`] or
[`REST_CATALOG_PROP_PAGE_SIZE`].
+//!
//! # Session catalog API
//!
//! ```rust, no_run