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

Reply via email to