This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-3295-e4f928acf70ff362c83f0fc4961bf7fc651ea00b in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
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
