Skip to main content

iceberg_catalog_rest/
catalog.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! This module contains the iceberg REST catalog implementation.
19
20use std::collections::{HashMap, HashSet};
21use std::fmt::{Debug, Formatter};
22use std::future::Future;
23use std::str::FromStr;
24use std::sync::{Arc, OnceLock};
25
26use async_trait::async_trait;
27use iceberg::encryption::kms::{KeyManagementClient, KmsClientFactory};
28use iceberg::io::{FileIO, FileIOBuilder, StorageFactory};
29use iceberg::table::Table;
30use iceberg::{
31    Catalog, CatalogBuilder, Error, ErrorKind, Namespace, NamespaceIdent, Result, Runtime,
32    SessionCatalog, SessionContext, TableCommit, TableCreation, TableIdent,
33};
34use itertools::Itertools;
35use reqwest::header::{
36    HeaderMap, HeaderName, HeaderValue, {self},
37};
38use reqwest::{Client, Method, StatusCode, Url};
39use tokio::sync::OnceCell;
40use typed_builder::TypedBuilder;
41
42use crate::auth::{AUTH_TYPE_NONE, AUTH_TYPE_OAUTH2, AuthManager, NoopAuthManager, OAuth2Manager};
43use crate::client::{
44    HttpClient, deserialize_catalog_response, deserialize_unexpected_catalog_error,
45};
46use crate::endpoint::{Endpoint, V1_NAMESPACE_EXISTS, V1_TABLE_EXISTS};
47use crate::request::HttpRequest;
48use crate::response::HttpResponse;
49use crate::types::{
50    CatalogConfig, CommitTableRequest, CommitTableResponse, CreateNamespaceRequest,
51    CreateTableRequest, ListNamespaceResponse, ListTablesResponse, LoadTableResult,
52    NamespaceResponse, RegisterTableRequest, RenameTableRequest,
53};
54
55/// REST catalog URI
56pub const REST_CATALOG_PROP_URI: &str = "uri";
57/// REST catalog warehouse location
58pub const REST_CATALOG_PROP_WAREHOUSE: &str = "warehouse";
59/// Disable header redaction in error logs and `Debug` output (defaults to
60/// false for security)
61pub const REST_CATALOG_PROP_DISABLE_HEADER_REDACTION: &str = "disable-header-redaction";
62/// Authentication scheme: `none` or `oauth2`. When unset, `oauth2` is used
63/// if a `token`, `credential` or `oauth2-server-uri` is configured, `none`
64/// otherwise.
65pub const REST_CATALOG_PROP_AUTH_TYPE: &str = "rest.auth.type";
66
67const ICEBERG_REST_SPEC_VERSION: &str = "0.14.1";
68const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
69const PATH_V1: &str = "v1";
70
71/// Builder for [`RestCatalog`], the [`Catalog`]-compatible façade over a
72/// [`RestSessionCatalog`].
73///
74/// The resulting catalog binds one [`SessionContext`] to every operation. Use
75/// [`RestSessionCatalogBuilder`] when the caller supplies a context per operation.
76#[derive(Debug, Default)]
77pub struct RestCatalogBuilder {
78    session_context: Option<SessionContext>,
79    inner: RestSessionCatalogBuilder,
80}
81
82impl CatalogBuilder for RestCatalogBuilder {
83    type C = RestCatalog;
84
85    fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
86        self.inner = self.inner.with_storage_factory(storage_factory);
87        self
88    }
89
90    fn with_kms_client_factory(mut self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self {
91        self.inner = self.inner.with_kms_client_factory(kms_client_factory);
92        self
93    }
94
95    fn with_runtime(mut self, runtime: Runtime) -> Self {
96        self.inner = self.inner.with_runtime(runtime);
97        self
98    }
99
100    fn load(
101        self,
102        name: impl Into<String>,
103        props: HashMap<String, String>,
104    ) -> impl Future<Output = Result<Self::C>> + Send {
105        let name = name.into();
106        async move {
107            let context = self.session_context.unwrap_or_else(SessionContext::empty);
108            let session_catalog = Arc::new(self.inner.load(name, props).await?);
109
110            Ok(RestCatalog::from_session_catalog(context, session_catalog))
111        }
112    }
113}
114
115impl RestCatalogBuilder {
116    /// Configures the catalog with a custom HTTP client.
117    pub fn with_client(mut self, client: Client) -> Self {
118        self.inner = self.inner.with_client(client);
119        self
120    }
121
122    /// Binds the session context forwarded with every catalog operation.
123    ///
124    /// If this is not called, [`load`](CatalogBuilder::load) creates a fresh
125    /// [`SessionContext::empty`] context.
126    pub fn with_session_context(mut self, context: SessionContext) -> Self {
127        self.session_context = Some(context);
128        self
129    }
130
131    /// Injects a custom auth manager, overriding the `rest.auth.type` configuration.
132    pub fn with_auth_manager(mut self, auth_manager: Arc<dyn AuthManager>) -> Self {
133        self.inner = self.inner.with_auth_manager(auth_manager);
134        self
135    }
136}
137
138/// Rest catalog configuration.
139#[derive(Clone, TypedBuilder)]
140pub(crate) struct RestCatalogConfig {
141    #[builder(default, setter(strip_option))]
142    name: Option<String>,
143
144    uri: String,
145
146    #[builder(default, setter(strip_option(fallback = warehouse_opt)))]
147    warehouse: Option<String>,
148
149    #[builder(default)]
150    props: HashMap<String, String>,
151
152    #[builder(default)]
153    client: Option<Client>,
154
155    /// Lazily-created default HTTP client, shared through clones of this
156    /// config so OAuth and catalog traffic reuse one connection pool
157    /// (matching the single-client behavior before the AuthManager refactor).
158    #[builder(default)]
159    default_client: Arc<OnceLock<Client>>,
160}
161
162/// Property keys whose values are secrets, or may embed them (headers,
163/// connection strings, keys like `adls.account-key` or `s3.sse.key`).
164fn is_sensitive_prop(key: &str) -> bool {
165    key.contains("token")
166        || key.contains("credential")
167        || key.contains("secret")
168        || key.contains("password")
169        || key.contains("key")
170        || key.contains("connection-string")
171        || key.starts_with("header.")
172}
173
174/// Redacts secret property values: this config is printed by the derived
175/// [`Debug`] implementations of [`RestSessionCatalog`] and [`RestCatalog`].
176impl Debug for RestCatalogConfig {
177    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
178        let props: HashMap<&str, &str> = self
179            .props
180            .iter()
181            .map(|(key, value)| {
182                let value = if is_sensitive_prop(key) {
183                    "[REDACTED]"
184                } else {
185                    value.as_str()
186                };
187                (key.as_str(), value)
188            })
189            .collect();
190        f.debug_struct("RestCatalogConfig")
191            .field("name", &self.name)
192            .field("uri", &self.uri)
193            .field("warehouse", &self.warehouse)
194            .field("props", &props)
195            .finish_non_exhaustive()
196    }
197}
198
199impl RestCatalogConfig {
200    fn url_prefixed(&self, parts: &[&str]) -> String {
201        [&self.uri, PATH_V1]
202            .into_iter()
203            .chain(self.props.get("prefix").map(|s| &**s))
204            .chain(parts.iter().cloned())
205            .join("/")
206    }
207
208    fn config_endpoint(&self) -> String {
209        [&self.uri, PATH_V1, "config"].join("/")
210    }
211
212    pub(crate) fn get_token_endpoint(&self) -> String {
213        self.explicit_oauth2_server_uri()
214            .unwrap_or_else(|| default_token_endpoint(&self.uri))
215    }
216
217    /// The `oauth2-server-uri` property, only when explicitly configured.
218    pub(crate) fn explicit_oauth2_server_uri(&self) -> Option<String> {
219        self.props.get("oauth2-server-uri").cloned()
220    }
221
222    fn namespaces_endpoint(&self) -> String {
223        self.url_prefixed(&["namespaces"])
224    }
225
226    fn namespace_endpoint(&self, ns: &NamespaceIdent) -> String {
227        self.url_prefixed(&["namespaces", &ns.to_url_string()])
228    }
229
230    fn tables_endpoint(&self, ns: &NamespaceIdent) -> String {
231        self.url_prefixed(&["namespaces", &ns.to_url_string(), "tables"])
232    }
233
234    fn rename_table_endpoint(&self) -> String {
235        self.url_prefixed(&["tables", "rename"])
236    }
237
238    fn register_table_endpoint(&self, ns: &NamespaceIdent) -> String {
239        self.url_prefixed(&["namespaces", &ns.to_url_string(), "register"])
240    }
241
242    fn table_endpoint(&self, table: &TableIdent) -> String {
243        self.url_prefixed(&[
244            "namespaces",
245            &table.namespace.to_url_string(),
246            "tables",
247            &table.name,
248        ])
249    }
250
251    /// The HTTP client: the configured one, or a lazily-created default that
252    /// is shared across every user of this config (and its clones), so token
253    /// and catalog requests keep sharing one connection pool.
254    pub(crate) fn client(&self) -> Client {
255        self.client
256            .clone()
257            .unwrap_or_else(|| self.default_client.get_or_init(Client::default).clone())
258    }
259
260    /// Get the token from the config.
261    ///
262    /// The client can use this token to send requests.
263    pub(crate) fn token(&self) -> Option<String> {
264        self.props.get("token").cloned()
265    }
266
267    /// Get the credentials from the config. The client can use these credentials to fetch a new
268    /// token.
269    pub(crate) fn credential(&self) -> Option<(Option<String>, String)> {
270        credential_from_props(&self.props)
271    }
272
273    /// Get the extra headers from config, see [`extra_headers_from_props`].
274    pub(crate) fn extra_headers(&self) -> Result<HeaderMap> {
275        extra_headers_from_props(&self.props)
276    }
277
278    /// Get the optional OAuth headers from the config.
279    pub(crate) fn extra_oauth_params(&self) -> HashMap<String, String> {
280        oauth_params_from_props(&self.props)
281    }
282
283    /// Check if header redaction is disabled in error logs.
284    ///
285    /// Returns true if the `disable-header-redaction` property is set to "true".
286    /// Defaults to false for security (headers are redacted by default).
287    pub(crate) fn disable_header_redaction(&self) -> bool {
288        self.props
289            .get(REST_CATALOG_PROP_DISABLE_HEADER_REDACTION)
290            .map(|v| v.eq_ignore_ascii_case("true"))
291            .unwrap_or(false)
292    }
293
294    /// Merge the `RestCatalogConfig` with the a [`CatalogConfig`] (fetched from the REST server).
295    pub(crate) fn merge_with_config(mut self, mut config: CatalogConfig) -> Self {
296        if let Some(uri) = config.overrides.remove(REST_CATALOG_PROP_URI) {
297            self.uri = uri;
298        }
299
300        let mut props = config.defaults;
301        props.extend(self.props);
302        // The builder moved the client warehouse off the props; restore it
303        // between defaults and overrides (default < client < override).
304        if let Some(warehouse) = &self.warehouse {
305            props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
306        }
307        props.extend(config.overrides);
308
309        self.props = props;
310        self
311    }
312}
313
314/// Parses the `credential` property.
315///
316/// ## Output
317///
318/// - `None`: No credential is set.
319/// - `Some(None, client_secret)`: No client_id is set, use client_secret directly.
320/// - `Some(Some(client_id), client_secret)`: Both client_id and client_secret are set.
321pub(crate) fn credential_from_props(
322    props: &HashMap<String, String>,
323) -> Option<(Option<String>, String)> {
324    let cred = props.get("credential")?;
325
326    match cred.split_once(':') {
327        Some((client_id, client_secret)) => {
328            Some((Some(client_id.to_string()), client_secret.to_string()))
329        }
330        None => Some((None, cred.to_string())),
331    }
332}
333
334/// The extra headers added to each request, which include:
335///
336/// - `content-type`
337/// - `x-client-version`
338/// - `user-agent`
339/// - All headers specified by `header.xxx` in props.
340pub(crate) fn extra_headers_from_props(props: &HashMap<String, String>) -> Result<HeaderMap> {
341    let mut headers = HeaderMap::from_iter([
342        (
343            header::CONTENT_TYPE,
344            HeaderValue::from_static("application/json"),
345        ),
346        (
347            HeaderName::from_static("x-client-version"),
348            HeaderValue::from_static(ICEBERG_REST_SPEC_VERSION),
349        ),
350        (
351            header::USER_AGENT,
352            HeaderValue::from_str(&format!("iceberg-rs/{CARGO_PKG_VERSION}")).unwrap(),
353        ),
354    ]);
355
356    headers.extend(explicit_headers_from_props(props)?);
357
358    Ok(headers)
359}
360
361/// The default OAuth2 token endpoint for a catalog `uri`.
362pub(crate) fn default_token_endpoint(uri: &str) -> String {
363    [uri, PATH_V1, "oauth", "tokens"].join("/")
364}
365
366/// Only the headers explicitly configured via `header.xxx` props (no defaults).
367pub(crate) fn explicit_headers_from_props(props: &HashMap<String, String>) -> Result<HeaderMap> {
368    let mut headers = HeaderMap::new();
369    for (key, value) in props
370        .iter()
371        .filter_map(|(k, v)| k.strip_prefix("header.").map(|k| (k, v)))
372    {
373        headers.insert(
374            HeaderName::from_str(key).map_err(|e| {
375                Error::new(
376                    ErrorKind::DataInvalid,
377                    format!("Invalid header name: {key}"),
378                )
379                .with_source(e)
380            })?,
381            HeaderValue::from_str(value).map_err(|e| {
382                Error::new(
383                    ErrorKind::DataInvalid,
384                    // The value itself is omitted: it may be a secret.
385                    format!("Invalid value for header: {key}"),
386                )
387                .with_source(e)
388            })?,
389        );
390    }
391
392    Ok(headers)
393}
394
395/// The optional OAuth parameters added to each authentication request.
396pub(crate) fn oauth_params_from_props(props: &HashMap<String, String>) -> HashMap<String, String> {
397    let mut params = HashMap::new();
398
399    if let Some(scope) = props.get("scope") {
400        params.insert("scope".to_string(), scope.to_string());
401    } else {
402        params.insert("scope".to_string(), "catalog".to_string());
403    }
404
405    let optional_params = ["audience", "resource"];
406    for param_name in optional_params {
407        if let Some(value) = props.get(param_name) {
408            params.insert(param_name.to_string(), value.to_string());
409        }
410    }
411
412    params
413}
414
415#[derive(Debug)]
416struct RestClient {
417    /// Carries the session the auth manager derived from the merged
418    /// configuration, so every request below is authenticated.
419    http_client: HttpClient,
420    /// Runtime config is fetched from rest server and stored here.
421    ///
422    /// It could be different from the user config.
423    config: RestCatalogConfig,
424    /// Capabilities the server advertises (see [`RestSessionCatalog::supports_endpoint`]).
425    endpoints: HashSet<Endpoint>,
426}
427
428impl RestClient {
429    /// Initializes the runtime config, advertised endpoints, and authentication
430    /// sessions shared by one REST catalog instance.
431    async fn init(
432        user_config: &RestCatalogConfig,
433        auth_manager: Arc<dyn AuthManager>,
434    ) -> Result<Self> {
435        let http_client = HttpClient::new(user_config)?;
436        // The init session lives only for the config handshake, so a
437        // manager whose session guards a one-shot resource can release
438        // it before deriving the catalog session.
439        let catalog_config = {
440            let init_session = auth_manager
441                .init_session(
442                    &http_client.without_auth_session(),
443                    &Self::auth_props(user_config),
444                )
445                .await?;
446            Self::load_config(
447                &http_client.with_auth_session(Arc::from(init_session)),
448                user_config,
449            )
450            .await?
451        };
452        // Use the advertised endpoints as-is, falling back to
453        // `DEFAULT_ENDPOINTS` when absent or empty.
454        let endpoints = match &catalog_config.endpoints {
455            Some(advertised) if !advertised.is_empty() => advertised.iter().cloned().collect(),
456            _ => crate::endpoint::DEFAULT_ENDPOINTS.clone(),
457        };
458        let config = user_config.clone().merge_with_config(catalog_config);
459        let http_client = http_client.update_with(&config)?;
460        // The manager is handed an unauthenticated client: its own
461        // requests must not be signed by the session it is deriving.
462        let session = auth_manager
463            .catalog_session(
464                &http_client.without_auth_session(),
465                &Self::auth_props(&config),
466            )
467            .await?;
468
469        Ok(Self {
470            config,
471            http_client: http_client.with_auth_session(session),
472            endpoints,
473        })
474    }
475
476    /// Testing only: the bearer token the catalog session would attach.
477    #[cfg(test)]
478    async fn token(&self) -> Option<String> {
479        self.http_client.token().await
480    }
481
482    /// Sends `request`, authenticated by the client's session.
483    async fn query_catalog(&self, request: HttpRequest) -> Result<HttpResponse> {
484        self.http_client.query_catalog(request).await
485    }
486
487    /// The properties handed to the [`AuthManager`], with the catalog `uri`
488    /// and `warehouse` made explicit.
489    fn auth_props(config: &RestCatalogConfig) -> HashMap<String, String> {
490        // `oauth2-server-uri` stays absent unless explicitly configured, so an
491        // injected manager keeps its own endpoint. The resolved `uri` and
492        // `warehouse` ARE passed: the builder moved them off the props, and
493        // the built-in manager recomputes its token endpoint from the URI.
494        let mut props = config.props.clone();
495        props.insert(REST_CATALOG_PROP_URI.to_string(), config.uri.clone());
496        if let Some(warehouse) = &config.warehouse {
497            // A fallback only: after the handshake the merged props hold
498            // the resolved warehouse, server override included.
499            props
500                .entry(REST_CATALOG_PROP_WAREHOUSE.to_string())
501                .or_insert_with(|| warehouse.clone());
502        }
503        props
504    }
505
506    /// Loads the runtime config from the server using `user_config`.
507    ///
508    /// It's required for a REST catalog to update its config after creation.
509    async fn load_config(
510        http_client: &HttpClient,
511        user_config: &RestCatalogConfig,
512    ) -> Result<CatalogConfig> {
513        let mut request_builder = http_client.request(Method::GET, user_config.config_endpoint());
514
515        if let Some(warehouse_location) = &user_config.warehouse {
516            request_builder = request_builder.query(&[("warehouse", warehouse_location)]);
517        }
518
519        let request = HttpRequest::build(request_builder)?;
520
521        let http_response = http_client.query_catalog(request).await?;
522
523        match http_response.status() {
524            StatusCode::OK => deserialize_catalog_response(http_response),
525            _ => Err(deserialize_unexpected_catalog_error(
526                http_response,
527                http_client.disable_header_redaction(),
528            )),
529        }
530    }
531}
532
533/// A [`Catalog`]-compatible façade over [`RestSessionCatalog`].
534///
535/// Every operation is forwarded with the single [`SessionContext`] selected by
536/// [`RestCatalogBuilder`]. Use [`RestSessionCatalog`] when the caller needs to
537/// provide a context per operation.
538#[derive(Debug)]
539pub struct RestCatalog {
540    session_context: SessionContext,
541    inner: Arc<RestSessionCatalog>,
542}
543
544impl RestCatalog {
545    /// Creates a `RestCatalog` from a [`RestCatalogConfig`].
546    #[cfg(test)]
547    fn new(
548        context: SessionContext,
549        config: RestCatalogConfig,
550        auth_manager: Option<Arc<dyn AuthManager>>,
551        storage_factory: Option<Arc<dyn StorageFactory>>,
552        runtime: Runtime,
553        kms_client: Option<Arc<dyn KeyManagementClient>>,
554    ) -> Self {
555        let session_catalog = Arc::new(RestSessionCatalog::new(
556            config,
557            auth_manager,
558            storage_factory,
559            runtime,
560            kms_client,
561        ));
562
563        Self::from_session_catalog(context, session_catalog)
564    }
565
566    fn from_session_catalog(context: SessionContext, inner: Arc<RestSessionCatalog>) -> Self {
567        Self {
568            session_context: context,
569            inner,
570        }
571    }
572
573    #[cfg(test)]
574    async fn client(&self) -> Result<&RestClient> {
575        self.inner.client().await
576    }
577}
578
579/// Every operation forwards to its [`RestSessionCatalog`] equivalent with the
580/// bound [`SessionContext`]; see that implementation for the REST-specific
581/// behavior.
582#[async_trait]
583impl Catalog for RestCatalog {
584    async fn list_namespaces(
585        &self,
586        parent: Option<&NamespaceIdent>,
587    ) -> Result<Vec<NamespaceIdent>> {
588        self.inner
589            .list_namespaces(&self.session_context, parent)
590            .await
591    }
592
593    async fn create_namespace(
594        &self,
595        namespace: &NamespaceIdent,
596        properties: HashMap<String, String>,
597    ) -> Result<Namespace> {
598        self.inner
599            .create_namespace(&self.session_context, namespace, properties)
600            .await
601    }
602
603    async fn get_namespace(&self, namespace: &NamespaceIdent) -> Result<Namespace> {
604        self.inner
605            .get_namespace(&self.session_context, namespace)
606            .await
607    }
608
609    async fn namespace_exists(&self, ns: &NamespaceIdent) -> Result<bool> {
610        self.inner.namespace_exists(&self.session_context, ns).await
611    }
612
613    async fn update_namespace(
614        &self,
615        namespace: &NamespaceIdent,
616        properties: HashMap<String, String>,
617    ) -> Result<()> {
618        self.inner
619            .update_namespace(&self.session_context, namespace, properties)
620            .await
621    }
622
623    async fn drop_namespace(&self, namespace: &NamespaceIdent) -> Result<()> {
624        self.inner
625            .drop_namespace(&self.session_context, namespace)
626            .await
627    }
628
629    async fn list_tables(&self, namespace: &NamespaceIdent) -> Result<Vec<TableIdent>> {
630        self.inner
631            .list_tables(&self.session_context, namespace)
632            .await
633    }
634
635    async fn create_table(
636        &self,
637        namespace: &NamespaceIdent,
638        creation: TableCreation,
639    ) -> Result<Table> {
640        self.inner
641            .create_table(&self.session_context, namespace, creation)
642            .await
643    }
644
645    async fn load_table(&self, table_ident: &TableIdent) -> Result<Table> {
646        self.inner
647            .load_table(&self.session_context, table_ident)
648            .await
649    }
650
651    async fn drop_table(&self, table: &TableIdent) -> Result<()> {
652        self.inner.drop_table(&self.session_context, table).await
653    }
654
655    async fn purge_table(&self, table: &TableIdent) -> Result<()> {
656        self.inner.purge_table(&self.session_context, table).await
657    }
658
659    async fn table_exists(&self, table: &TableIdent) -> Result<bool> {
660        self.inner.table_exists(&self.session_context, table).await
661    }
662
663    async fn rename_table(&self, src: &TableIdent, dest: &TableIdent) -> Result<()> {
664        self.inner
665            .rename_table(&self.session_context, src, dest)
666            .await
667    }
668
669    async fn register_table(
670        &self,
671        table_ident: &TableIdent,
672        metadata_location: String,
673    ) -> Result<Table> {
674        self.inner
675            .register_table(&self.session_context, table_ident, metadata_location)
676            .await
677    }
678
679    async fn update_table(&self, commit: TableCommit) -> Result<Table> {
680        self.inner.update_table(&self.session_context, commit).await
681    }
682}
683
684/// REST catalog implementation of [`SessionCatalog`].
685///
686/// Each operation accepts a [`SessionContext`]. REST configuration, authentication sessions,
687/// and the HTTP client are initialized lazily once per catalog and shared across all operations.
688#[derive(Debug)]
689pub struct RestSessionCatalog {
690    /// Injected through [`RestSessionCatalogBuilder::with_auth_manager`]; otherwise
691    /// one is resolved from `rest.auth.type` when the client is built.
692    auth_manager: Option<Arc<dyn AuthManager>>,
693    /// User config is stored as-is and never changed.
694    ///
695    /// It could be different from the config fetched from the server and used at runtime.
696    user_config: RestCatalogConfig,
697    client: OnceCell<RestClient>,
698    /// Storage factory for creating FileIO instances.
699    storage_factory: Option<Arc<dyn StorageFactory>>,
700    runtime: Runtime,
701    /// Optional KMS client for encrypted tables.
702    kms_client: Option<Arc<dyn KeyManagementClient>>,
703}
704
705impl RestSessionCatalog {
706    /// Creates a `RestSessionCatalog` from a [`RestCatalogConfig`].
707    fn new(
708        config: RestCatalogConfig,
709        auth_manager: Option<Arc<dyn AuthManager>>,
710        storage_factory: Option<Arc<dyn StorageFactory>>,
711        runtime: Runtime,
712        kms_client: Option<Arc<dyn KeyManagementClient>>,
713    ) -> Self {
714        Self {
715            auth_manager,
716            user_config: config,
717            client: OnceCell::new(),
718            storage_factory,
719            runtime,
720            kms_client,
721        }
722    }
723
724    /// Sends a DELETE request for the given table, optionally requesting purge.
725    async fn delete_table(
726        &self,
727        _context: &SessionContext,
728        table: &TableIdent,
729        purge: bool,
730    ) -> Result<()> {
731        let client = self.client().await?;
732
733        let mut request_builder = client
734            .http_client
735            .request(Method::DELETE, client.config.table_endpoint(table));
736
737        if purge {
738            request_builder = request_builder.query(&[("purgeRequested", "true")]);
739        }
740
741        let request = HttpRequest::build(request_builder)?;
742        let http_response = client.query_catalog(request).await?;
743
744        match http_response.status() {
745            StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
746            StatusCode::NOT_FOUND => Err(Error::new(
747                ErrorKind::TableNotFound,
748                "Tried to drop a table that does not exist",
749            )),
750            _ => Err(deserialize_unexpected_catalog_error(
751                http_response,
752                client.http_client.disable_header_redaction(),
753            )),
754        }
755    }
756
757    /// The configured auth scheme: explicit `rest.auth.type` (matched
758    /// case-insensitively) when set; otherwise `oauth2` when a `token`,
759    /// `credential` or `oauth2-server-uri` is configured (preserving
760    /// pre-`rest.auth.type` setups), `none` when none is.
761    fn auth_type(config: &RestCatalogConfig) -> String {
762        config
763            .props
764            .get(REST_CATALOG_PROP_AUTH_TYPE)
765            // Matched case-insensitively, as the other flag properties are.
766            .map(|auth_type| auth_type.to_ascii_lowercase())
767            .unwrap_or_else(|| {
768                if config.token().is_some()
769                    || config.credential().is_some()
770                    || config.explicit_oauth2_server_uri().is_some()
771                {
772                    AUTH_TYPE_OAUTH2.to_string()
773                } else {
774                    AUTH_TYPE_NONE.to_string()
775                }
776            })
777    }
778
779    /// Resolves the auth manager: a `with_auth_manager` override wins,
780    /// otherwise one is built from the `rest.auth.type` configuration.
781    fn resolve_auth_manager(&self) -> Result<Arc<dyn AuthManager>> {
782        if let Some(auth_manager) = &self.auth_manager {
783            return Ok(auth_manager.clone());
784        }
785        let config = &self.user_config;
786        let auth_type = Self::auth_type(config);
787        // Java parity (`AuthManagers`): make the inference visible so users
788        // configure the type explicitly.
789        if auth_type == AUTH_TYPE_OAUTH2 && !config.props.contains_key(REST_CATALOG_PROP_AUTH_TYPE)
790        {
791            tracing::warn!(
792                "Inferring {REST_CATALOG_PROP_AUTH_TYPE}={AUTH_TYPE_OAUTH2} from the configured \
793                 OAuth properties; set it explicitly to avoid this warning"
794            );
795        }
796        match auth_type.as_str() {
797            AUTH_TYPE_NONE => Ok(Arc::new(NoopAuthManager)),
798            AUTH_TYPE_OAUTH2 => Ok(Arc::new(OAuth2Manager::from_config(config)?)),
799            other => Err(Error::new(
800                ErrorKind::DataInvalid,
801                format!(
802                    "unknown '{REST_CATALOG_PROP_AUTH_TYPE}': {other}; use \
803                     `RestSessionCatalogBuilder::with_auth_manager` or \
804                     `RestCatalogBuilder::with_auth_manager` to inject a custom auth manager"
805                ),
806            )),
807        }
808    }
809
810    /// Gets the [`RestClient`] from the catalog.
811    async fn client(&self) -> Result<&RestClient> {
812        self.client
813            .get_or_try_init(|| async {
814                RestClient::init(&self.user_config, self.resolve_auth_manager()?).await
815            })
816            .await
817    }
818
819    /// Returns whether the server supports `endpoint`, per the `endpoints` it
820    /// advertised in `GET /v1/config` (or a default base set when it advertised
821    /// none).
822    pub(crate) async fn supports_endpoint(&self, endpoint: &Endpoint) -> Result<bool> {
823        Ok(self.client().await?.endpoints.contains(endpoint))
824    }
825
826    /// Issue a `HEAD` request to `url` and interpret it as an existence check:
827    /// `2xx` means it exists, `404` means it doesn't.
828    async fn check_exists_via_head(&self, client: &RestClient, url: String) -> Result<bool> {
829        let request = HttpRequest::build(client.http_client.request(Method::HEAD, url))?;
830        let http_response = client.query_catalog(request).await?;
831
832        match http_response.status() {
833            StatusCode::NO_CONTENT | StatusCode::OK => Ok(true),
834            StatusCode::NOT_FOUND => Ok(false),
835            _ => Err(deserialize_unexpected_catalog_error(
836                http_response,
837                client.http_client.disable_header_redaction(),
838            )),
839        }
840    }
841
842    async fn load_file_io(
843        &self,
844        metadata_location: Option<&str>,
845        extra_config: Option<HashMap<String, String>>,
846    ) -> Result<FileIO> {
847        let mut props = self.client().await?.config.props.clone();
848        if let Some(config) = extra_config {
849            props.extend(config);
850        }
851
852        // If the warehouse is a logical identifier instead of a URL we don't want
853        // to raise an exception
854        let warehouse_path = match self.client().await?.config.warehouse.as_deref() {
855            Some(url) if Url::parse(url).is_ok() => Some(url),
856            Some(_) => None,
857            None => None,
858        };
859
860        if metadata_location.or(warehouse_path).is_none() {
861            return Err(Error::new(
862                ErrorKind::Unexpected,
863                "Unable to load file io, neither warehouse nor metadata location is set!",
864            ));
865        }
866
867        // Require a StorageFactory to be provided
868        let factory = self
869            .storage_factory
870            .clone()
871            .ok_or_else(|| {
872                Error::new(
873                    ErrorKind::Unexpected,
874                    "StorageFactory must be provided for REST catalog table operations. Use `with_storage_factory` to configure it.",
875                )
876            })?;
877
878        let file_io = FileIOBuilder::new(factory).with_props(props).build();
879
880        Ok(file_io)
881    }
882}
883
884/// All requests and expected responses are derived from the REST catalog API spec:
885/// <https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml>
886#[async_trait]
887impl SessionCatalog for RestSessionCatalog {
888    async fn list_namespaces(
889        &self,
890        _context: &SessionContext,
891        parent: Option<&NamespaceIdent>,
892    ) -> Result<Vec<NamespaceIdent>> {
893        let client = self.client().await?;
894        let endpoint = client.config.namespaces_endpoint();
895        let mut namespaces = Vec::new();
896        let mut next_token = None;
897
898        loop {
899            let mut request = client.http_client.request(Method::GET, endpoint.clone());
900
901            // Filter on `parent={namespace}` if a parent namespace exists.
902            if let Some(ns) = parent {
903                request = request.query(&[("parent", ns.to_url_string())]);
904            }
905
906            if let Some(token) = next_token {
907                request = request.query(&[("pageToken", token)]);
908            }
909
910            let http_response = client.query_catalog(HttpRequest::build(request)?).await?;
911
912            match http_response.status() {
913                StatusCode::OK => {
914                    let response =
915                        deserialize_catalog_response::<ListNamespaceResponse>(http_response)?;
916
917                    namespaces.extend(response.namespaces);
918
919                    match response.next_page_token {
920                        Some(token) => next_token = Some(token),
921                        None => break,
922                    }
923                }
924                StatusCode::NOT_FOUND => {
925                    return Err(Error::new(
926                        ErrorKind::NamespaceNotFound,
927                        "The parent parameter of the namespace provided does not exist",
928                    ));
929                }
930                _ => {
931                    return Err(deserialize_unexpected_catalog_error(
932                        http_response,
933                        client.http_client.disable_header_redaction(),
934                    ));
935                }
936            }
937        }
938
939        Ok(namespaces)
940    }
941
942    async fn create_namespace(
943        &self,
944        _context: &SessionContext,
945        namespace: &NamespaceIdent,
946        properties: HashMap<String, String>,
947    ) -> Result<Namespace> {
948        let client = self.client().await?;
949
950        let request = HttpRequest::build(
951            client
952                .http_client
953                .request(Method::POST, client.config.namespaces_endpoint())
954                .json(&CreateNamespaceRequest {
955                    namespace: namespace.clone(),
956                    properties,
957                }),
958        )?;
959
960        let http_response = client.query_catalog(request).await?;
961
962        match http_response.status() {
963            StatusCode::OK => {
964                let response = deserialize_catalog_response::<NamespaceResponse>(http_response)?;
965                Ok(Namespace::from(response))
966            }
967            StatusCode::CONFLICT => Err(Error::new(
968                ErrorKind::NamespaceAlreadyExists,
969                "Tried to create a namespace that already exists",
970            )),
971            _ => Err(deserialize_unexpected_catalog_error(
972                http_response,
973                client.http_client.disable_header_redaction(),
974            )),
975        }
976    }
977
978    async fn get_namespace(
979        &self,
980        _context: &SessionContext,
981        namespace: &NamespaceIdent,
982    ) -> Result<Namespace> {
983        let client = self.client().await?;
984
985        let request = HttpRequest::build(
986            client
987                .http_client
988                .request(Method::GET, client.config.namespace_endpoint(namespace)),
989        )?;
990
991        let http_response = client.query_catalog(request).await?;
992
993        match http_response.status() {
994            StatusCode::OK => {
995                let response = deserialize_catalog_response::<NamespaceResponse>(http_response)?;
996                Ok(Namespace::from(response))
997            }
998            StatusCode::NOT_FOUND => Err(Error::new(
999                ErrorKind::NamespaceNotFound,
1000                "Tried to get a namespace that does not exist",
1001            )),
1002            _ => Err(deserialize_unexpected_catalog_error(
1003                http_response,
1004                client.http_client.disable_header_redaction(),
1005            )),
1006        }
1007    }
1008
1009    async fn namespace_exists(
1010        &self,
1011        context: &SessionContext,
1012        ns: &NamespaceIdent,
1013    ) -> Result<bool> {
1014        // Prefer a cheap HEAD when the server advertises it; otherwise fall back
1015        // to loading the namespace (GET) and treating a missing namespace as
1016        // `false`, so this still works against servers that don't advertise the
1017        // HEAD route.
1018        if !self.supports_endpoint(&V1_NAMESPACE_EXISTS).await? {
1019            return match self.get_namespace(context, ns).await {
1020                Ok(_) => Ok(true),
1021                Err(e) if e.kind() == ErrorKind::NamespaceNotFound => Ok(false),
1022                Err(e) => Err(e),
1023            };
1024        }
1025
1026        let client = self.client().await?;
1027        self.check_exists_via_head(client, client.config.namespace_endpoint(ns))
1028            .await
1029    }
1030
1031    async fn update_namespace(
1032        &self,
1033        _context: &SessionContext,
1034        _namespace: &NamespaceIdent,
1035        _properties: HashMap<String, String>,
1036    ) -> Result<()> {
1037        Err(Error::new(
1038            ErrorKind::FeatureUnsupported,
1039            "Updating namespace not supported yet!",
1040        ))
1041    }
1042
1043    async fn drop_namespace(
1044        &self,
1045        _context: &SessionContext,
1046        namespace: &NamespaceIdent,
1047    ) -> Result<()> {
1048        let client = self.client().await?;
1049
1050        let request = HttpRequest::build(
1051            client
1052                .http_client
1053                .request(Method::DELETE, client.config.namespace_endpoint(namespace)),
1054        )?;
1055
1056        let http_response = client.query_catalog(request).await?;
1057
1058        match http_response.status() {
1059            StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
1060            StatusCode::NOT_FOUND => Err(Error::new(
1061                ErrorKind::NamespaceNotFound,
1062                "Tried to drop a namespace that does not exist",
1063            )),
1064            _ => Err(deserialize_unexpected_catalog_error(
1065                http_response,
1066                client.http_client.disable_header_redaction(),
1067            )),
1068        }
1069    }
1070
1071    async fn list_tables(
1072        &self,
1073        _context: &SessionContext,
1074        namespace: &NamespaceIdent,
1075    ) -> Result<Vec<TableIdent>> {
1076        let client = self.client().await?;
1077        let endpoint = client.config.tables_endpoint(namespace);
1078        let mut identifiers = Vec::new();
1079        let mut next_token = None;
1080
1081        loop {
1082            let mut request = client.http_client.request(Method::GET, endpoint.clone());
1083
1084            if let Some(token) = next_token {
1085                request = request.query(&[("pageToken", token)]);
1086            }
1087
1088            let http_response = client.query_catalog(HttpRequest::build(request)?).await?;
1089
1090            match http_response.status() {
1091                StatusCode::OK => {
1092                    let response =
1093                        deserialize_catalog_response::<ListTablesResponse>(http_response)?;
1094
1095                    identifiers.extend(response.identifiers);
1096
1097                    match response.next_page_token {
1098                        Some(token) => next_token = Some(token),
1099                        None => break,
1100                    }
1101                }
1102                StatusCode::NOT_FOUND => {
1103                    return Err(Error::new(
1104                        ErrorKind::NamespaceNotFound,
1105                        "Tried to list tables of a namespace that does not exist",
1106                    ));
1107                }
1108                _ => {
1109                    return Err(deserialize_unexpected_catalog_error(
1110                        http_response,
1111                        client.http_client.disable_header_redaction(),
1112                    ));
1113                }
1114            }
1115        }
1116
1117        Ok(identifiers)
1118    }
1119
1120    /// Create a new table inside the namespace.
1121    ///
1122    /// In the resulting table, if there are any config properties that
1123    /// are present in both the response from the REST server and the
1124    /// config provided when creating this `RestSessionCatalog` instance, then
1125    /// the value provided locally to the `RestSessionCatalog` will take precedence.
1126    async fn create_table(
1127        &self,
1128        _context: &SessionContext,
1129        namespace: &NamespaceIdent,
1130        creation: TableCreation,
1131    ) -> Result<Table> {
1132        let client = self.client().await?;
1133
1134        let table_ident = TableIdent::new(namespace.clone(), creation.name.clone());
1135
1136        let request = HttpRequest::build(
1137            client
1138                .http_client
1139                .request(Method::POST, client.config.tables_endpoint(namespace))
1140                .json(&CreateTableRequest {
1141                    name: creation.name,
1142                    location: creation.location,
1143                    schema: creation.schema,
1144                    partition_spec: creation.partition_spec,
1145                    write_order: creation.sort_order,
1146                    stage_create: Some(false),
1147                    properties: creation.properties,
1148                }),
1149        )?;
1150
1151        let http_response = client.query_catalog(request).await?;
1152
1153        let response = match http_response.status() {
1154            StatusCode::OK => deserialize_catalog_response::<LoadTableResult>(http_response)?,
1155            StatusCode::NOT_FOUND => {
1156                return Err(Error::new(
1157                    ErrorKind::NamespaceNotFound,
1158                    "Tried to create a table under a namespace that does not exist",
1159                ));
1160            }
1161            StatusCode::CONFLICT => {
1162                return Err(Error::new(
1163                    ErrorKind::TableAlreadyExists,
1164                    "The table already exists",
1165                ));
1166            }
1167            _ => {
1168                return Err(deserialize_unexpected_catalog_error(
1169                    http_response,
1170                    client.http_client.disable_header_redaction(),
1171                ));
1172            }
1173        };
1174
1175        let metadata_location = response.metadata_location.as_ref().ok_or(Error::new(
1176            ErrorKind::DataInvalid,
1177            "Metadata location missing in `create_table` response!",
1178        ))?;
1179
1180        let config = response
1181            .config
1182            .into_iter()
1183            .chain(self.user_config.props.clone())
1184            .collect();
1185
1186        let file_io = self
1187            .load_file_io(Some(metadata_location), Some(config))
1188            .await?;
1189
1190        let mut table_builder = Table::builder()
1191            .identifier(table_ident.clone())
1192            .file_io(file_io)
1193            .metadata(response.metadata)
1194            .runtime(self.runtime.clone());
1195        if let Some(kms_client) = self.kms_client.clone() {
1196            table_builder = table_builder.kms_client(kms_client);
1197        }
1198
1199        if let Some(metadata_location) = response.metadata_location {
1200            table_builder.metadata_location(metadata_location).build()
1201        } else {
1202            table_builder.build()
1203        }
1204    }
1205
1206    /// Load table from the catalog.
1207    ///
1208    /// If there are any config properties that are present in both the response from the REST
1209    /// server and the config provided when creating this `RestSessionCatalog` instance, then the
1210    /// value provided locally to the `RestSessionCatalog` will take precedence.
1211    async fn load_table(
1212        &self,
1213        _context: &SessionContext,
1214        table_ident: &TableIdent,
1215    ) -> Result<Table> {
1216        let client = self.client().await?;
1217
1218        let request = HttpRequest::build(
1219            client
1220                .http_client
1221                .request(Method::GET, client.config.table_endpoint(table_ident)),
1222        )?;
1223
1224        let http_response = client.query_catalog(request).await?;
1225
1226        let response = match http_response.status() {
1227            StatusCode::OK | StatusCode::NOT_MODIFIED => {
1228                deserialize_catalog_response::<LoadTableResult>(http_response)?
1229            }
1230            StatusCode::NOT_FOUND => {
1231                return Err(Error::new(
1232                    ErrorKind::TableNotFound,
1233                    "Tried to load a table that does not exist",
1234                ));
1235            }
1236            _ => {
1237                return Err(deserialize_unexpected_catalog_error(
1238                    http_response,
1239                    client.http_client.disable_header_redaction(),
1240                ));
1241            }
1242        };
1243
1244        let config = response
1245            .config
1246            .into_iter()
1247            .chain(self.user_config.props.clone())
1248            .collect();
1249
1250        let file_io = self
1251            .load_file_io(response.metadata_location.as_deref(), Some(config))
1252            .await?;
1253
1254        let mut table_builder = Table::builder()
1255            .identifier(table_ident.clone())
1256            .file_io(file_io)
1257            .metadata(response.metadata)
1258            .runtime(self.runtime.clone());
1259        if let Some(kms_client) = self.kms_client.clone() {
1260            table_builder = table_builder.kms_client(kms_client);
1261        }
1262
1263        if let Some(metadata_location) = response.metadata_location {
1264            table_builder.metadata_location(metadata_location).build()
1265        } else {
1266            table_builder.build()
1267        }
1268    }
1269
1270    /// Drop a table from the catalog.
1271    async fn drop_table(&self, context: &SessionContext, table: &TableIdent) -> Result<()> {
1272        self.delete_table(context, table, false).await
1273    }
1274
1275    /// Drop a table from the catalog and purge its data by sending
1276    /// `purgeRequested=true` to the REST server.
1277    async fn purge_table(&self, context: &SessionContext, table: &TableIdent) -> Result<()> {
1278        self.delete_table(context, table, true).await
1279    }
1280
1281    /// Check if a table exists in the catalog.
1282    async fn table_exists(&self, context: &SessionContext, table: &TableIdent) -> Result<bool> {
1283        // Prefer a cheap HEAD when the server advertises it; otherwise fall back
1284        // to loading the table (GET) and treating a missing table as `false`, so
1285        // this still works against servers that don't advertise the HEAD route.
1286        if !self.supports_endpoint(&V1_TABLE_EXISTS).await? {
1287            return match self.load_table(context, table).await {
1288                Ok(_) => Ok(true),
1289                Err(e) if e.kind() == ErrorKind::TableNotFound => Ok(false),
1290                Err(e) => Err(e),
1291            };
1292        }
1293
1294        let client = self.client().await?;
1295        self.check_exists_via_head(client, client.config.table_endpoint(table))
1296            .await
1297    }
1298
1299    /// Rename a table in the catalog.
1300    async fn rename_table(
1301        &self,
1302        _context: &SessionContext,
1303        src: &TableIdent,
1304        dest: &TableIdent,
1305    ) -> Result<()> {
1306        let client = self.client().await?;
1307
1308        let request = HttpRequest::build(
1309            client
1310                .http_client
1311                .request(Method::POST, client.config.rename_table_endpoint())
1312                .json(&RenameTableRequest {
1313                    source: src.clone(),
1314                    destination: dest.clone(),
1315                }),
1316        )?;
1317
1318        let http_response = client.query_catalog(request).await?;
1319
1320        match http_response.status() {
1321            StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
1322            StatusCode::NOT_FOUND => Err(Error::new(
1323                ErrorKind::TableNotFound,
1324                "Tried to rename a table that does not exist (is the namespace correct?)",
1325            )),
1326            StatusCode::CONFLICT => Err(Error::new(
1327                ErrorKind::TableAlreadyExists,
1328                "Tried to rename a table to a name that already exists",
1329            )),
1330            _ => Err(deserialize_unexpected_catalog_error(
1331                http_response,
1332                client.http_client.disable_header_redaction(),
1333            )),
1334        }
1335    }
1336
1337    async fn register_table(
1338        &self,
1339        _context: &SessionContext,
1340        table_ident: &TableIdent,
1341        metadata_location: String,
1342    ) -> Result<Table> {
1343        let client = self.client().await?;
1344
1345        let request = HttpRequest::build(
1346            client
1347                .http_client
1348                .request(
1349                    Method::POST,
1350                    client
1351                        .config
1352                        .register_table_endpoint(table_ident.namespace()),
1353                )
1354                .json(&RegisterTableRequest {
1355                    name: table_ident.name.clone(),
1356                    metadata_location: metadata_location.clone(),
1357                    overwrite: Some(false),
1358                }),
1359        )?;
1360
1361        let http_response = client.query_catalog(request).await?;
1362
1363        let response: LoadTableResult = match http_response.status() {
1364            StatusCode::OK => deserialize_catalog_response::<LoadTableResult>(http_response)?,
1365            StatusCode::NOT_FOUND => {
1366                return Err(Error::new(
1367                    ErrorKind::NamespaceNotFound,
1368                    "The namespace specified does not exist.",
1369                ));
1370            }
1371            StatusCode::CONFLICT => {
1372                return Err(Error::new(
1373                    ErrorKind::TableAlreadyExists,
1374                    "The given table already exists.",
1375                ));
1376            }
1377            _ => {
1378                return Err(deserialize_unexpected_catalog_error(
1379                    http_response,
1380                    client.http_client.disable_header_redaction(),
1381                ));
1382            }
1383        };
1384
1385        let metadata_location = response.metadata_location.as_ref().ok_or(Error::new(
1386            ErrorKind::DataInvalid,
1387            "Metadata location missing in `register_table` response!",
1388        ))?;
1389
1390        let file_io = self.load_file_io(Some(metadata_location), None).await?;
1391
1392        let mut table_builder = Table::builder()
1393            .identifier(table_ident.clone())
1394            .file_io(file_io)
1395            .metadata(response.metadata)
1396            .metadata_location(metadata_location.clone())
1397            .runtime(self.runtime.clone());
1398        if let Some(kms_client) = self.kms_client.clone() {
1399            table_builder = table_builder.kms_client(kms_client);
1400        }
1401        table_builder.build()
1402    }
1403
1404    async fn update_table(
1405        &self,
1406        _context: &SessionContext,
1407        mut commit: TableCommit,
1408    ) -> Result<Table> {
1409        let client = self.client().await?;
1410
1411        let request = HttpRequest::build(
1412            client
1413                .http_client
1414                .request(
1415                    Method::POST,
1416                    client.config.table_endpoint(commit.identifier()),
1417                )
1418                .json(&CommitTableRequest {
1419                    identifier: Some(commit.identifier().clone()),
1420                    requirements: commit.take_requirements(),
1421                    updates: commit.take_updates(),
1422                }),
1423        )?;
1424
1425        let http_response = client.query_catalog(request).await?;
1426
1427        let response: CommitTableResponse = match http_response.status() {
1428            StatusCode::OK => deserialize_catalog_response(http_response)?,
1429            StatusCode::NOT_FOUND => {
1430                return Err(Error::new(
1431                    ErrorKind::TableNotFound,
1432                    "Tried to update a table that does not exist",
1433                ));
1434            }
1435            StatusCode::CONFLICT => {
1436                return Err(Error::new(
1437                    ErrorKind::CatalogCommitConflicts,
1438                    "CatalogCommitConflicts, one or more requirements failed. The client may retry.",
1439                )
1440                .with_retryable(true));
1441            }
1442            StatusCode::INTERNAL_SERVER_ERROR => {
1443                return Err(Error::new(
1444                    ErrorKind::Unexpected,
1445                    "An unknown server-side problem occurred; the commit state is unknown.",
1446                ));
1447            }
1448            StatusCode::BAD_GATEWAY => {
1449                return Err(Error::new(
1450                    ErrorKind::Unexpected,
1451                    "A gateway or proxy received an invalid response from the upstream server; the commit state is unknown.",
1452                ));
1453            }
1454            StatusCode::GATEWAY_TIMEOUT => {
1455                return Err(Error::new(
1456                    ErrorKind::Unexpected,
1457                    "A server-side gateway timeout occurred; the commit state is unknown.",
1458                ));
1459            }
1460            _ => {
1461                return Err(deserialize_unexpected_catalog_error(
1462                    http_response,
1463                    client.http_client.disable_header_redaction(),
1464                ));
1465            }
1466        };
1467
1468        let file_io = self
1469            .load_file_io(Some(&response.metadata_location), None)
1470            .await?;
1471
1472        let mut table_builder = Table::builder()
1473            .identifier(commit.identifier().clone())
1474            .file_io(file_io)
1475            .metadata(response.metadata)
1476            .metadata_location(response.metadata_location)
1477            .runtime(self.runtime.clone());
1478        if let Some(kms_client) = self.kms_client.clone() {
1479            table_builder = table_builder.kms_client(kms_client);
1480        }
1481        table_builder.build()
1482    }
1483}
1484
1485/// Builder for an unbound [`RestSessionCatalog`].
1486///
1487/// Unlike [`RestCatalogBuilder`], the resulting catalog accepts a
1488/// [`SessionContext`] with each [`SessionCatalog`] operation.
1489#[derive(Debug)]
1490pub struct RestSessionCatalogBuilder {
1491    config: RestCatalogConfig,
1492    auth_manager: Option<Arc<dyn AuthManager>>,
1493    storage_factory: Option<Arc<dyn StorageFactory>>,
1494    kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
1495    runtime: Option<Runtime>,
1496}
1497
1498impl Default for RestSessionCatalogBuilder {
1499    fn default() -> Self {
1500        Self {
1501            config: RestCatalogConfig {
1502                name: None,
1503                uri: "".to_string(),
1504                warehouse: None,
1505                props: HashMap::new(),
1506                client: None,
1507                default_client: Arc::new(OnceLock::new()),
1508            },
1509            auth_manager: None,
1510            storage_factory: None,
1511            kms_client_factory: None,
1512            runtime: None,
1513        }
1514    }
1515}
1516
1517impl RestSessionCatalogBuilder {
1518    /// Configures the catalog with a custom HTTP client.
1519    pub fn with_client(mut self, client: Client) -> Self {
1520        self.config.client = Some(client);
1521        self
1522    }
1523
1524    /// Injects a custom auth manager, overriding the `rest.auth.type` configuration.
1525    pub fn with_auth_manager(mut self, auth_manager: Arc<dyn AuthManager>) -> Self {
1526        self.auth_manager = Some(auth_manager);
1527        self
1528    }
1529
1530    /// Set a custom StorageFactory to use for storage operations.
1531    ///
1532    /// When a StorageFactory is provided, the catalog will use it to build FileIO
1533    /// instances for all storage operations instead of using the default factory.
1534    ///
1535    /// # Arguments
1536    ///
1537    /// * `storage_factory` - The StorageFactory to use for creating storage instances
1538    ///
1539    /// # Example
1540    ///
1541    /// ```rust,ignore
1542    /// use iceberg::io::StorageFactory;
1543    /// use iceberg_catalog_rest::RestSessionCatalogBuilder;
1544    /// use iceberg_storage_opendal::OpenDalStorageFactory;
1545    /// use std::sync::Arc;
1546    ///
1547    /// let catalog = RestSessionCatalogBuilder::default()
1548    ///     .with_storage_factory(Arc::new(OpenDalStorageFactory::S3 {
1549    ///         customized_credential_load: None,
1550    ///     }))
1551    ///     .load("my_catalog", props)
1552    ///     .await?;
1553    /// ```
1554    pub fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
1555        self.storage_factory = Some(storage_factory);
1556        self
1557    }
1558
1559    /// Set a [`KmsClientFactory`] to enable table encryption.
1560    ///
1561    /// When provided, the catalog calls the factory once during
1562    /// [`load`](Self::load) with the catalog properties to create a shared
1563    /// [`KeyManagementClient`].
1564    /// That client is then passed to each table's `TableBuilder` so tables
1565    /// with `encryption.key-id` set can construct an `EncryptionManager`.
1566    ///
1567    /// # Example
1568    ///
1569    /// ```rust,ignore
1570    /// use iceberg::encryption::kms::KmsClientFactory;
1571    /// use iceberg_catalog_rest::RestSessionCatalogBuilder;
1572    /// use std::sync::Arc;
1573    ///
1574    /// let catalog = RestSessionCatalogBuilder::default()
1575    ///     .with_kms_client_factory(Arc::new(MyKmsClientFactory))
1576    ///     .load("my_catalog", props)
1577    ///     .await?;
1578    /// ```
1579    pub fn with_kms_client_factory(
1580        mut self,
1581        kms_client_factory: Arc<dyn KmsClientFactory>,
1582    ) -> Self {
1583        self.kms_client_factory = Some(kms_client_factory);
1584        self
1585    }
1586
1587    /// Set a custom tokio Runtime to use for spawning async tasks.
1588    ///
1589    /// When a Runtime is provided, the catalog will propagate it to all tables
1590    /// it creates. Tasks such as scan planning and delete file processing
1591    /// will be spawned on this runtime.
1592    pub fn with_runtime(mut self, runtime: Runtime) -> Self {
1593        self.runtime = Some(runtime);
1594        self
1595    }
1596
1597    /// Creates a new session catalog instance.
1598    ///
1599    /// The server configuration handshake, endpoint negotiation, and
1600    /// authentication sessions are initialized lazily on the first operation.
1601    pub fn load(
1602        mut self,
1603        name: impl Into<String>,
1604        props: HashMap<String, String>,
1605    ) -> impl Future<Output = Result<RestSessionCatalog>> + Send {
1606        self.config.name = Some(name.into());
1607
1608        if props.contains_key(REST_CATALOG_PROP_URI) {
1609            self.config.uri = props
1610                .get(REST_CATALOG_PROP_URI)
1611                .cloned()
1612                .unwrap_or_default();
1613        }
1614
1615        if props.contains_key(REST_CATALOG_PROP_WAREHOUSE) {
1616            self.config.warehouse = props.get(REST_CATALOG_PROP_WAREHOUSE).cloned()
1617        }
1618
1619        // Collect other remaining properties
1620        self.config.props = props
1621            .into_iter()
1622            .filter(|(k, _)| k != REST_CATALOG_PROP_URI && k != REST_CATALOG_PROP_WAREHOUSE)
1623            .collect();
1624
1625        async move {
1626            if self.config.name.is_none() {
1627                Err(Error::new(
1628                    ErrorKind::DataInvalid,
1629                    "Catalog name is required",
1630                ))
1631            } else if self.config.uri.is_empty() {
1632                Err(Error::new(
1633                    ErrorKind::DataInvalid,
1634                    "Catalog uri is required",
1635                ))
1636            } else {
1637                let runtime = self.runtime.unwrap_or_else(Runtime::current);
1638                let kms_client = match self.kms_client_factory {
1639                    Some(factory) => Some(factory.create_kms_client(&self.config.props).await?),
1640                    None => None,
1641                };
1642
1643                Ok(RestSessionCatalog::new(
1644                    self.config,
1645                    self.auth_manager,
1646                    self.storage_factory,
1647                    runtime,
1648                    kms_client,
1649                ))
1650            }
1651        }
1652    }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657    use std::fs::File;
1658    use std::io::BufReader;
1659    use std::sync::Arc;
1660
1661    use chrono::{TimeZone, Utc};
1662    use iceberg::io::LocalFsStorageFactory;
1663    use iceberg::spec::{
1664        FormatVersion, NestedField, NullOrder, Operation, PrimitiveType, Schema, Snapshot,
1665        SnapshotLog, SortDirection, SortField, SortOrder, Summary, Transform, Type,
1666        UnboundPartitionField, UnboundPartitionSpec,
1667    };
1668    use iceberg::test_utils::test_runtime;
1669    use iceberg::transaction::{ApplyTransactionAction, Transaction};
1670    use mockito::{Mock, Server, ServerGuard};
1671    use serde_json::json;
1672    use uuid::uuid;
1673
1674    use super::*;
1675    use crate::auth::AuthSession;
1676    use crate::request::HttpRequest;
1677
1678    fn test_catalog(config: RestCatalogConfig) -> RestSessionCatalog {
1679        test_catalog_with(config, None)
1680    }
1681
1682    fn test_catalog_with(
1683        config: RestCatalogConfig,
1684        auth_manager: Option<Arc<dyn AuthManager>>,
1685    ) -> RestSessionCatalog {
1686        RestSessionCatalog::new(config, auth_manager, None, Runtime::current(), None)
1687    }
1688
1689    fn test_client() -> HttpClient {
1690        HttpClient::new(
1691            &RestCatalogConfig::builder()
1692                .uri("http://localhost".to_string())
1693                .build(),
1694        )
1695        .unwrap()
1696    }
1697
1698    /// Builds a [`RestSessionCatalog`] with the default test storage factory and runtime.
1699    fn session_catalog(config: RestCatalogConfig) -> RestSessionCatalog {
1700        RestSessionCatalog::new(
1701            config,
1702            None,
1703            Some(Arc::new(LocalFsStorageFactory)),
1704            Runtime::current(),
1705            None,
1706        )
1707    }
1708
1709    #[tokio::test]
1710    async fn test_update_config() {
1711        let mut server = Server::new_async().await;
1712
1713        let config_mock = server
1714            .mock("GET", "/v1/config")
1715            .with_status(200)
1716            .with_body(
1717                r#"{
1718                "overrides": {
1719                    "warehouse": "s3://iceberg-catalog"
1720                },
1721                "defaults": {}
1722            }"#,
1723            )
1724            .create_async()
1725            .await;
1726
1727        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
1728
1729        assert_eq!(
1730            catalog
1731                .client()
1732                .await
1733                .unwrap()
1734                .config
1735                .props
1736                .get("warehouse"),
1737            Some(&"s3://iceberg-catalog".to_string())
1738        );
1739
1740        config_mock.assert_async().await;
1741    }
1742
1743    async fn create_config_mock(server: &mut ServerGuard) -> Mock {
1744        server
1745            .mock("GET", "/v1/config")
1746            .with_status(200)
1747            .with_body(
1748                r#"{
1749                "overrides": {
1750                    "warehouse": "s3://iceberg-catalog"
1751                },
1752                "defaults": {}
1753            }"#,
1754            )
1755            .create_async()
1756            .await
1757    }
1758
1759    /// Config mock that advertises the HEAD table/namespace-exists endpoints, so
1760    /// `{table,namespace}_exists` take the HEAD path rather than the GET fallback.
1761    async fn create_config_mock_with_exists_endpoints(server: &mut ServerGuard) -> Mock {
1762        server
1763            .mock("GET", "/v1/config")
1764            .with_status(200)
1765            .with_body(
1766                r#"{
1767                "overrides": { "warehouse": "s3://iceberg-catalog" },
1768                "defaults": {},
1769                "endpoints": [
1770                    "HEAD /v1/{prefix}/namespaces/{namespace}",
1771                    "HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}"
1772                ]
1773            }"#,
1774            )
1775            .create_async()
1776            .await
1777    }
1778
1779    #[tokio::test]
1780    async fn test_config_advertised_endpoints() {
1781        let mut server = Server::new_async().await;
1782
1783        let config_mock = server
1784            .mock("GET", "/v1/config")
1785            .with_status(200)
1786            .with_body(
1787                r#"{
1788                "overrides": {},
1789                "defaults": {},
1790                "endpoints": [
1791                    "GET /v1/{prefix}/namespaces",
1792                    "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan"
1793                ]
1794            }"#,
1795            )
1796            .create_async()
1797            .await;
1798
1799        let catalog = RestCatalog::new(
1800            SessionContext::empty(),
1801            RestCatalogConfig::builder().uri(server.url()).build(),
1802            None,
1803            Some(Arc::new(LocalFsStorageFactory)),
1804            Runtime::current(),
1805            None,
1806        );
1807
1808        let plan = "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan"
1809            .parse::<Endpoint>()
1810            .unwrap();
1811        assert!(catalog.inner.supports_endpoint(&plan).await.unwrap());
1812        // Advertised list is present but does not include this route.
1813        let delete_ns = "DELETE /v1/{prefix}/namespaces/{namespace}"
1814            .parse::<Endpoint>()
1815            .unwrap();
1816        assert!(!catalog.inner.supports_endpoint(&delete_ns).await.unwrap());
1817
1818        config_mock.assert_async().await;
1819    }
1820
1821    #[tokio::test]
1822    async fn test_config_without_endpoints_falls_back_to_default_set() {
1823        let mut server = Server::new_async().await;
1824
1825        let config_mock = server
1826            .mock("GET", "/v1/config")
1827            .with_status(200)
1828            .with_body(r#"{ "overrides": {}, "defaults": {} }"#)
1829            .create_async()
1830            .await;
1831
1832        let catalog = RestCatalog::new(
1833            SessionContext::empty(),
1834            RestCatalogConfig::builder().uri(server.url()).build(),
1835            None,
1836            Some(Arc::new(LocalFsStorageFactory)),
1837            Runtime::current(),
1838            None,
1839        );
1840
1841        // A server that omits the `endpoints` field is assumed to support the
1842        // standard base operations.
1843        let load_table = "GET /v1/{prefix}/namespaces/{namespace}/tables/{table}"
1844            .parse::<Endpoint>()
1845            .unwrap();
1846        assert!(catalog.inner.supports_endpoint(&load_table).await.unwrap());
1847        // But not an optional endpoint that must be advertised.
1848        let plan = "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan"
1849            .parse::<Endpoint>()
1850            .unwrap();
1851        assert!(!catalog.inner.supports_endpoint(&plan).await.unwrap());
1852
1853        config_mock.assert_async().await;
1854    }
1855
1856    #[tokio::test]
1857    async fn test_config_with_empty_endpoints_falls_back_to_default_set() {
1858        let mut server = Server::new_async().await;
1859
1860        // An explicit empty list is treated the same as an absent field: fall
1861        // back to the standard base set.
1862        let config_mock = server
1863            .mock("GET", "/v1/config")
1864            .with_status(200)
1865            .with_body(r#"{ "overrides": {}, "defaults": {}, "endpoints": [] }"#)
1866            .create_async()
1867            .await;
1868
1869        let catalog = RestCatalog::new(
1870            SessionContext::empty(),
1871            RestCatalogConfig::builder().uri(server.url()).build(),
1872            None,
1873            Some(Arc::new(LocalFsStorageFactory)),
1874            Runtime::current(),
1875            None,
1876        );
1877
1878        let load_table = "GET /v1/{prefix}/namespaces/{namespace}/tables/{table}"
1879            .parse::<Endpoint>()
1880            .unwrap();
1881        assert!(catalog.inner.supports_endpoint(&load_table).await.unwrap());
1882
1883        config_mock.assert_async().await;
1884    }
1885
1886    async fn create_oauth_mock(server: &mut ServerGuard) -> Mock {
1887        create_oauth_mock_with_path(server, "/v1/oauth/tokens", "ey000000000000", 200).await
1888    }
1889
1890    async fn create_oauth_mock_with_path(
1891        server: &mut ServerGuard,
1892        path: &str,
1893        token: &str,
1894        status: usize,
1895    ) -> Mock {
1896        let body = format!(
1897            r#"{{
1898                "access_token": "{token}",
1899                "token_type": "Bearer",
1900                "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1901                "expires_in": 86400
1902            }}"#
1903        );
1904        server
1905            .mock("POST", path)
1906            .with_status(status)
1907            .with_body(body)
1908            .expect(1)
1909            .create_async()
1910            .await
1911    }
1912
1913    #[tokio::test]
1914    async fn test_oauth() {
1915        let mut server = Server::new_async().await;
1916        let oauth_mock = create_oauth_mock(&mut server).await;
1917        let config_mock = create_config_mock(&mut server).await;
1918
1919        let mut props = HashMap::new();
1920        props.insert("credential".to_string(), "client1:secret1".to_string());
1921
1922        let catalog = session_catalog(
1923            RestCatalogConfig::builder()
1924                .uri(server.url())
1925                .props(props)
1926                .build(),
1927        );
1928
1929        let token = catalog.client().await.unwrap().token().await;
1930        oauth_mock.assert_async().await;
1931        config_mock.assert_async().await;
1932        assert_eq!(token, Some("ey000000000000".to_string()));
1933    }
1934
1935    #[tokio::test]
1936    async fn test_oauth_with_optional_param() {
1937        let mut props = HashMap::new();
1938        props.insert("credential".to_string(), "client1:secret1".to_string());
1939        props.insert("scope".to_string(), "custom_scope".to_string());
1940        props.insert("audience".to_string(), "custom_audience".to_string());
1941        props.insert("resource".to_string(), "custom_resource".to_string());
1942
1943        let mut server = Server::new_async().await;
1944        let oauth_mock = server
1945            .mock("POST", "/v1/oauth/tokens")
1946            .match_body(mockito::Matcher::Regex("scope=custom_scope".to_string()))
1947            .match_body(mockito::Matcher::Regex(
1948                "audience=custom_audience".to_string(),
1949            ))
1950            .match_body(mockito::Matcher::Regex(
1951                "resource=custom_resource".to_string(),
1952            ))
1953            .with_status(200)
1954            .with_body(
1955                r#"{
1956                "access_token": "ey000000000000",
1957                "token_type": "Bearer",
1958                "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1959                "expires_in": 86400
1960                }"#,
1961            )
1962            .expect(1)
1963            .create_async()
1964            .await;
1965
1966        let config_mock = create_config_mock(&mut server).await;
1967
1968        let catalog = session_catalog(
1969            RestCatalogConfig::builder()
1970                .uri(server.url())
1971                .props(props)
1972                .build(),
1973        );
1974
1975        let token = catalog.client().await.unwrap().token().await;
1976
1977        oauth_mock.assert_async().await;
1978        config_mock.assert_async().await;
1979        assert_eq!(token, Some("ey000000000000".to_string()));
1980    }
1981
1982    #[tokio::test]
1983    async fn test_http_headers() {
1984        let server = Server::new_async().await;
1985        let mut props = HashMap::new();
1986        props.insert("credential".to_string(), "client1:secret1".to_string());
1987
1988        let config = RestCatalogConfig::builder()
1989            .uri(server.url())
1990            .props(props)
1991            .build();
1992        let headers: HeaderMap = config.extra_headers().unwrap();
1993
1994        let expected_headers = HeaderMap::from_iter([
1995            (
1996                header::CONTENT_TYPE,
1997                HeaderValue::from_static("application/json"),
1998            ),
1999            (
2000                HeaderName::from_static("x-client-version"),
2001                HeaderValue::from_static(ICEBERG_REST_SPEC_VERSION),
2002            ),
2003            (
2004                header::USER_AGENT,
2005                HeaderValue::from_str(&format!("iceberg-rs/{CARGO_PKG_VERSION}")).unwrap(),
2006            ),
2007        ]);
2008        assert_eq!(headers, expected_headers);
2009    }
2010
2011    #[tokio::test]
2012    async fn test_http_headers_with_custom_headers() {
2013        let server = Server::new_async().await;
2014        let mut props = HashMap::new();
2015        props.insert("credential".to_string(), "client1:secret1".to_string());
2016        props.insert(
2017            "header.content-type".to_string(),
2018            "application/yaml".to_string(),
2019        );
2020        props.insert(
2021            "header.customized-header".to_string(),
2022            "some/value".to_string(),
2023        );
2024
2025        let config = RestCatalogConfig::builder()
2026            .uri(server.url())
2027            .props(props)
2028            .build();
2029        let headers: HeaderMap = config.extra_headers().unwrap();
2030
2031        let expected_headers = HeaderMap::from_iter([
2032            (
2033                header::CONTENT_TYPE,
2034                HeaderValue::from_static("application/yaml"),
2035            ),
2036            (
2037                HeaderName::from_static("x-client-version"),
2038                HeaderValue::from_static(ICEBERG_REST_SPEC_VERSION),
2039            ),
2040            (
2041                header::USER_AGENT,
2042                HeaderValue::from_str(&format!("iceberg-rs/{CARGO_PKG_VERSION}")).unwrap(),
2043            ),
2044            (
2045                HeaderName::from_static("customized-header"),
2046                HeaderValue::from_static("some/value"),
2047            ),
2048        ]);
2049        assert_eq!(headers, expected_headers);
2050    }
2051
2052    #[tokio::test]
2053    async fn test_oauth_with_oauth2_server_uri() {
2054        let mut server = Server::new_async().await;
2055        let config_mock = create_config_mock(&mut server).await;
2056
2057        let mut auth_server = Server::new_async().await;
2058        let auth_server_path = "/some/path";
2059        let oauth_mock =
2060            create_oauth_mock_with_path(&mut auth_server, auth_server_path, "ey000000000000", 200)
2061                .await;
2062
2063        let mut props = HashMap::new();
2064        props.insert("credential".to_string(), "client1:secret1".to_string());
2065        props.insert(
2066            "oauth2-server-uri".to_string(),
2067            format!("{}{}", auth_server.url(), auth_server_path).to_string(),
2068        );
2069
2070        let catalog = session_catalog(
2071            RestCatalogConfig::builder()
2072                .uri(server.url())
2073                .props(props)
2074                .build(),
2075        );
2076
2077        let token = catalog.client().await.unwrap().token().await;
2078
2079        oauth_mock.assert_async().await;
2080        config_mock.assert_async().await;
2081        assert_eq!(token, Some("ey000000000000".to_string()));
2082    }
2083
2084    #[tokio::test]
2085    async fn test_config_override() {
2086        let mut server = Server::new_async().await;
2087        let mut redirect_server = Server::new_async().await;
2088        let new_uri = redirect_server.url();
2089
2090        let config_mock = server
2091            .mock("GET", "/v1/config")
2092            .with_status(200)
2093            .with_body(
2094                json!(
2095                    {
2096                        "overrides": {
2097                            "uri": new_uri,
2098                            "warehouse": "s3://iceberg-catalog",
2099                            "prefix": "ice/warehouses/my"
2100                        },
2101                        "defaults": {},
2102                    }
2103                )
2104                .to_string(),
2105            )
2106            .create_async()
2107            .await;
2108
2109        let list_ns_mock = redirect_server
2110            .mock("GET", "/v1/ice/warehouses/my/namespaces")
2111            .with_body(
2112                r#"{
2113                    "namespaces": []
2114                }"#,
2115            )
2116            .create_async()
2117            .await;
2118
2119        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
2120
2121        let _namespaces = catalog
2122            .list_namespaces(&SessionContext::empty(), None)
2123            .await
2124            .unwrap();
2125
2126        config_mock.assert_async().await;
2127        list_ns_mock.assert_async().await;
2128    }
2129
2130    #[tokio::test]
2131    async fn test_list_namespace() {
2132        let mut server = Server::new_async().await;
2133
2134        let config_mock = create_config_mock(&mut server).await;
2135
2136        let list_ns_mock = server
2137            .mock("GET", "/v1/namespaces")
2138            .with_body(
2139                r#"{
2140                "namespaces": [
2141                    ["ns1", "ns11"],
2142                    ["ns2"]
2143                ]
2144            }"#,
2145            )
2146            .create_async()
2147            .await;
2148
2149        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
2150
2151        let namespaces = catalog
2152            .list_namespaces(&SessionContext::empty(), None)
2153            .await
2154            .unwrap();
2155
2156        let expected_ns = vec![
2157            NamespaceIdent::from_vec(vec!["ns1".to_string(), "ns11".to_string()]).unwrap(),
2158            NamespaceIdent::from_vec(vec!["ns2".to_string()]).unwrap(),
2159        ];
2160
2161        assert_eq!(expected_ns, namespaces);
2162
2163        config_mock.assert_async().await;
2164        list_ns_mock.assert_async().await;
2165    }
2166
2167    #[tokio::test]
2168    async fn test_auth_type_none_disables_auth() {
2169        // An explicit `rest.auth.type=none` wins over a configured token.
2170        let props = HashMap::from([
2171            (REST_CATALOG_PROP_AUTH_TYPE.to_string(), "none".to_string()),
2172            ("token".to_string(), "some-oauth-token".to_string()),
2173        ]);
2174        let config = RestCatalogConfig::builder()
2175            .uri("http://localhost".to_string())
2176            .props(props)
2177            .build();
2178
2179        let session = test_catalog(config)
2180            .resolve_auth_manager()
2181            .unwrap()
2182            .init_session(&test_client(), &HashMap::new())
2183            .await
2184            .unwrap();
2185        let mut req = HttpRequest::new(
2186            Client::new()
2187                .get("https://rest.example.com/v1/config")
2188                .build()
2189                .unwrap(),
2190        );
2191        session.authenticate(&mut req).await.unwrap();
2192        assert!(req.headers().get("authorization").is_none());
2193    }
2194
2195    #[tokio::test]
2196    async fn test_header_prop_overrides_token_on_the_wire() {
2197        // Pre-AuthManager behavior, preserved: extra headers are applied after
2198        // authentication, so a user-configured `header.authorization` wins
2199        // over a configured token.
2200        let mut server = Server::new_async().await;
2201        let config_mock = create_config_mock(&mut server).await;
2202        let list_ns_mock = server
2203            .mock("GET", "/v1/namespaces")
2204            .match_header("authorization", "Basic xyz")
2205            .with_body(r#"{"namespaces": []}"#)
2206            .create_async()
2207            .await;
2208
2209        let props = HashMap::from([
2210            ("token".to_string(), "some-oauth-token".to_string()),
2211            ("header.authorization".to_string(), "Basic xyz".to_string()),
2212        ]);
2213        let catalog = RestCatalog::new(
2214            SessionContext::empty(),
2215            RestCatalogConfig::builder()
2216                .uri(server.url())
2217                .props(props)
2218                .build(),
2219            None,
2220            Some(Arc::new(LocalFsStorageFactory)),
2221            Runtime::current(),
2222            None,
2223        );
2224
2225        catalog.list_namespaces(None).await.unwrap();
2226        config_mock.assert_async().await;
2227        list_ns_mock.assert_async().await;
2228    }
2229
2230    #[tokio::test]
2231    async fn test_builtin_oauth_endpoint_follows_uri_override() {
2232        // When `/v1/config` overrides `uri` (and no explicit `oauth2-server-uri`
2233        // is set), the built-in manager's default token endpoint must follow
2234        // the merged URI.
2235        let mut bootstrap = Server::new_async().await;
2236        let overridden = Server::new_async().await;
2237
2238        let config_mock = bootstrap
2239            .mock("GET", "/v1/config")
2240            .with_status(200)
2241            .with_body(format!(
2242                r#"{{"overrides": {{"uri": "{}"}}, "defaults": {{}}}}"#,
2243                overridden.url()
2244            ))
2245            .create_async()
2246            .await;
2247        // Handshake exchange still uses the bootstrap-derived default.
2248        let bootstrap_oauth_mock =
2249            create_oauth_mock_with_path(&mut bootstrap, "/v1/oauth/tokens", "tok-boot", 200).await;
2250
2251        let props = HashMap::from([("credential".to_string(), "client1:secret1".to_string())]);
2252        let catalog = RestCatalog::new(
2253            SessionContext::empty(),
2254            RestCatalogConfig::builder()
2255                .uri(bootstrap.url())
2256                .props(props)
2257                .build(),
2258            None,
2259            Some(Arc::new(LocalFsStorageFactory)),
2260            Runtime::current(),
2261            None,
2262        );
2263
2264        let client = catalog.client().await.unwrap();
2265        config_mock.assert_async().await;
2266        bootstrap_oauth_mock.assert_async().await;
2267        // The catalog session's endpoint follows the overridden URI (visible
2268        // via the session's Debug, which prints its token endpoint).
2269        let session_debug = format!("{:?}", client.http_client.auth_session());
2270        assert!(session_debug.contains(&format!("{}/v1/oauth/tokens", overridden.url())));
2271    }
2272
2273    #[tokio::test]
2274    async fn test_concurrent_authenticate_single_token_exchange() {
2275        // Concurrent requests that all find no cached token must trigger ONE
2276        // credential exchange (the lock is held across it), not one each.
2277        let mut server = Server::new_async().await;
2278        // create_oauth_mock_with_path expects exactly 1 hit.
2279        let oauth_mock =
2280            create_oauth_mock_with_path(&mut server, "/v1/oauth/tokens", "tok-once", 200).await;
2281
2282        let manager = OAuth2Manager::new(format!("{}/v1/oauth/tokens", server.url()))
2283            .with_credential(Some("client1".to_string()), "secret1".to_string());
2284        let session: Arc<dyn AuthSession> = Arc::from(
2285            manager
2286                .init_session(&test_client(), &HashMap::new())
2287                .await
2288                .unwrap(),
2289        );
2290
2291        let client = Client::new();
2292        let attempts = (0..8).map(|_| {
2293            let session = session.clone();
2294            let client = client.clone();
2295            async move {
2296                let mut req = HttpRequest::new(
2297                    client
2298                        .get("https://rest.example.com/v1/config")
2299                        .build()
2300                        .unwrap(),
2301                );
2302                session.authenticate(&mut req).await.unwrap();
2303                req.headers()
2304                    .get("authorization")
2305                    .unwrap()
2306                    .to_str()
2307                    .unwrap()
2308                    .to_string()
2309            }
2310        });
2311        let bearers = futures::future::join_all(attempts).await;
2312
2313        oauth_mock.assert_async().await;
2314        assert!(bearers.iter().all(|b| b == "Bearer tok-once"));
2315    }
2316
2317    #[tokio::test]
2318    async fn test_seeded_token_takes_precedence_over_credential() {
2319        // token + credential: the seeded token is attached without any
2320        // credential exchange.
2321        let mut server = Server::new_async().await;
2322        let oauth_mock = server
2323            .mock("POST", "/v1/oauth/tokens")
2324            .expect(0)
2325            .create_async()
2326            .await;
2327
2328        let manager = OAuth2Manager::new(format!("{}/v1/oauth/tokens", server.url()))
2329            .with_token("tok-seed")
2330            .with_credential(Some("client1".to_string()), "secret1".to_string());
2331        let session = manager
2332            .init_session(&test_client(), &HashMap::new())
2333            .await
2334            .unwrap();
2335
2336        let mut req = HttpRequest::new(
2337            Client::new()
2338                .get("https://rest.example.com/v1/config")
2339                .build()
2340                .unwrap(),
2341        );
2342        session.authenticate(&mut req).await.unwrap();
2343        assert_eq!(
2344            req.headers().get("authorization").unwrap(),
2345            "Bearer tok-seed"
2346        );
2347
2348        oauth_mock.assert_async().await;
2349    }
2350
2351    #[tokio::test]
2352    async fn test_injected_oauth_manager_keeps_endpoint_and_options() {
2353        // An injected OAuth2Manager must keep its own token endpoint, extra
2354        // headers and OAuth params across the config handshake: only explicit
2355        // properties may override them, never synthesized defaults.
2356        let mut server = Server::new_async().await;
2357        // The server vends the credential, so the exchange runs through the
2358        // post-handshake catalog session (exercising its property merging).
2359        let config_mock = server
2360            .mock("GET", "/v1/config")
2361            .with_status(200)
2362            .with_body(r#"{"defaults": {"credential": "client1:secret1"}, "overrides": {}}"#)
2363            .create_async()
2364            .await;
2365
2366        // The catalog-host default endpoint must never see the credential.
2367        let default_endpoint_mock = server
2368            .mock("POST", "/v1/oauth/tokens")
2369            .expect(0)
2370            .create_async()
2371            .await;
2372        // The exchange hits the injected endpoint, carrying the injected
2373        // header and OAuth param.
2374        let custom_endpoint_mock = server
2375            .mock("POST", "/custom/oauth/tokens")
2376            .match_header("x-tenant", "t1")
2377            // The default catalog scope must survive alongside the injected
2378            // audience (with_extra_oauth_params merges onto the defaults).
2379            .match_body(mockito::Matcher::AllOf(vec![
2380                mockito::Matcher::Regex("scope=catalog".to_string()),
2381                mockito::Matcher::Regex("audience=aud-1".to_string()),
2382            ]))
2383            .with_status(200)
2384            .with_body(
2385                r#"{
2386                "access_token": "ey000000000000",
2387                "token_type": "Bearer",
2388                "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2389                "expires_in": 86400
2390                }"#,
2391            )
2392            .create_async()
2393            .await;
2394        let ns_mock = server
2395            .mock("GET", "/v1/namespaces")
2396            .match_header("authorization", "Bearer ey000000000000")
2397            .with_body(r#"{"namespaces": []}"#)
2398            .create_async()
2399            .await;
2400
2401        let manager = OAuth2Manager::new(format!("{}/custom/oauth/tokens", server.url()))
2402            .with_extra_headers(HeaderMap::from_iter([(
2403                HeaderName::from_static("x-tenant"),
2404                HeaderValue::from_static("t1"),
2405            )]))
2406            .with_extra_oauth_params(HashMap::from([(
2407                "audience".to_string(),
2408                "aud-1".to_string(),
2409            )]));
2410        let catalog = RestCatalog::new(
2411            SessionContext::empty(),
2412            RestCatalogConfig::builder().uri(server.url()).build(),
2413            Some(Arc::new(manager)),
2414            Some(Arc::new(LocalFsStorageFactory)),
2415            Runtime::current(),
2416            None,
2417        );
2418
2419        catalog.list_namespaces(None).await.unwrap();
2420
2421        config_mock.assert_async().await;
2422        custom_endpoint_mock.assert_async().await;
2423        default_endpoint_mock.assert_async().await;
2424        ns_mock.assert_async().await;
2425    }
2426
2427    #[tokio::test]
2428    async fn test_props_token_takes_precedence_over_props_credential() {
2429        // Both arriving through the properties rather than the builder.
2430        let mut server = Server::new_async().await;
2431        let oauth_mock = server
2432            .mock("POST", "/v1/oauth/tokens")
2433            .expect(0)
2434            .create_async()
2435            .await;
2436
2437        let manager = OAuth2Manager::new(format!("{}/v1/oauth/tokens", server.url()));
2438        let session = manager
2439            .init_session(
2440                &test_client(),
2441                &HashMap::from([
2442                    ("token".to_string(), "tok-props".to_string()),
2443                    ("credential".to_string(), "client1:secret1".to_string()),
2444                ]),
2445            )
2446            .await
2447            .unwrap();
2448
2449        let mut req = HttpRequest::new(
2450            Client::new()
2451                .get("https://rest.example.com/v1/config")
2452                .build()
2453                .unwrap(),
2454        );
2455        session.authenticate(&mut req).await.unwrap();
2456        assert_eq!(
2457            req.headers().get("authorization").unwrap(),
2458            "Bearer tok-props"
2459        );
2460        oauth_mock.assert_async().await;
2461    }
2462
2463    #[tokio::test]
2464    async fn test_manager_exchanges_over_the_catalog_client() {
2465        // The manager exchanges the credential over the client it is handed,
2466        // sharing the catalog's connection pool. Its own extra headers go on
2467        // the token request; the catalog's do not.
2468        let mut server = Server::new_async().await;
2469        let token_mock = server
2470            .mock("POST", "/v1/oauth/tokens")
2471            .match_header("x-from", "manager")
2472            .match_header("x-catalog-only", mockito::Matcher::Missing)
2473            .with_status(200)
2474            .with_body(r#"{"access_token": "tok", "token_type": "Bearer"}"#)
2475            .create_async()
2476            .await;
2477
2478        let catalog_client = HttpClient::new(
2479            &RestCatalogConfig::builder()
2480                .uri(server.url())
2481                .props(HashMap::from([(
2482                    "header.x-catalog-only".to_string(),
2483                    "not-on-token-requests".to_string(),
2484                )]))
2485                .build(),
2486        )
2487        .unwrap();
2488
2489        let manager = OAuth2Manager::new(format!("{}/v1/oauth/tokens", server.url()))
2490            .with_credential(Some("client1".to_string()), "secret1".to_string())
2491            .with_extra_headers(HeaderMap::from_iter([(
2492                HeaderName::from_static("x-from"),
2493                HeaderValue::from_static("manager"),
2494            )]));
2495        let session = manager
2496            .init_session(&catalog_client, &HashMap::new())
2497            .await
2498            .unwrap();
2499
2500        let mut req = HttpRequest::new(
2501            Client::new()
2502                .get("https://rest.example.com/v1/namespaces")
2503                .build()
2504                .unwrap(),
2505        );
2506        session.authenticate(&mut req).await.unwrap();
2507        token_mock.assert_async().await;
2508    }
2509
2510    #[tokio::test]
2511    async fn test_handshake_is_authenticated_by_the_init_session() {
2512        // `/v1/config` goes out with the init session's authentication, not
2513        // unauthenticated and not with a later one.
2514        let mut server = Server::new_async().await;
2515        let config_mock = server
2516            .mock("GET", "/v1/config")
2517            .match_header("authorization", "Bearer tok-init")
2518            .with_status(200)
2519            .with_body(r#"{"defaults": {}, "overrides": {}}"#)
2520            .create_async()
2521            .await;
2522
2523        let catalog = RestCatalog::new(
2524            SessionContext::empty(),
2525            RestCatalogConfig::builder()
2526                .uri(server.url())
2527                .props(HashMap::from([(
2528                    "token".to_string(),
2529                    "tok-init".to_string(),
2530                )]))
2531                .build(),
2532            None,
2533            Some(Arc::new(LocalFsStorageFactory)),
2534            Runtime::current(),
2535            None,
2536        );
2537
2538        catalog.client().await.unwrap();
2539        config_mock.assert_async().await;
2540    }
2541
2542    #[tokio::test]
2543    async fn test_init_session_receives_user_props() {
2544        use tokio::sync::Mutex as AsyncMutex;
2545
2546        // A custom manager initializes from the user configuration: the
2547        // props carry the catalog `uri` and the user's credentials.
2548        #[derive(Debug)]
2549        struct PlainSession;
2550        #[async_trait]
2551        impl AuthSession for PlainSession {
2552            async fn authenticate(&self, _request: &mut HttpRequest) -> Result<()> {
2553                Ok(())
2554            }
2555        }
2556
2557        #[derive(Debug)]
2558        struct CapturingManager(Arc<AsyncMutex<Option<HashMap<String, String>>>>);
2559        #[async_trait]
2560        impl AuthManager for CapturingManager {
2561            async fn init_session(
2562                &self,
2563                _client: &HttpClient,
2564                props: &HashMap<String, String>,
2565            ) -> Result<Box<dyn AuthSession>> {
2566                *self.0.lock().await = Some(props.clone());
2567                Ok(Box::new(PlainSession))
2568            }
2569            async fn catalog_session(
2570                &self,
2571                _client: &HttpClient,
2572                _props: &HashMap<String, String>,
2573            ) -> Result<Arc<dyn AuthSession>> {
2574                Ok(Arc::new(PlainSession))
2575            }
2576        }
2577
2578        let mut server = Server::new_async().await;
2579        let config_mock = create_config_mock(&mut server).await;
2580        let captured = Arc::new(AsyncMutex::new(None));
2581        let catalog = RestCatalog::new(
2582            SessionContext::empty(),
2583            RestCatalogConfig::builder()
2584                .uri(server.url())
2585                .props(HashMap::from([(
2586                    "token".to_string(),
2587                    "tok-user".to_string(),
2588                )]))
2589                .build(),
2590            Some(Arc::new(CapturingManager(captured.clone()))),
2591            Some(Arc::new(LocalFsStorageFactory)),
2592            Runtime::current(),
2593            None,
2594        );
2595
2596        catalog.client().await.unwrap();
2597        config_mock.assert_async().await;
2598        let props = captured.lock().await.clone().unwrap();
2599        assert_eq!(props.get("token").map(String::as_str), Some("tok-user"));
2600        assert_eq!(
2601            props.get(REST_CATALOG_PROP_URI).map(String::as_str),
2602            Some(server.url().as_str())
2603        );
2604    }
2605
2606    #[tokio::test]
2607    async fn test_catalog_session_receives_resolved_warehouse() {
2608        use tokio::sync::Mutex as AsyncMutex;
2609
2610        // A custom manager must receive the resolved warehouse in the props
2611        // handed to `catalog_session`, with the standard precedence:
2612        // server default < client-side warehouse < server override.
2613        #[derive(Debug)]
2614        struct PlainSession;
2615        #[async_trait]
2616        impl AuthSession for PlainSession {
2617            async fn authenticate(&self, _request: &mut HttpRequest) -> Result<()> {
2618                Ok(())
2619            }
2620        }
2621
2622        #[derive(Debug)]
2623        struct CapturingManager(Arc<AsyncMutex<Option<HashMap<String, String>>>>);
2624        #[async_trait]
2625        impl AuthManager for CapturingManager {
2626            async fn init_session(
2627                &self,
2628                _client: &HttpClient,
2629                _props: &HashMap<String, String>,
2630            ) -> Result<Box<dyn AuthSession>> {
2631                Ok(Box::new(PlainSession))
2632            }
2633            async fn catalog_session(
2634                &self,
2635                _client: &HttpClient,
2636                props: &HashMap<String, String>,
2637            ) -> Result<Arc<dyn AuthSession>> {
2638                *self.0.lock().await = Some(props.clone());
2639                Ok(Arc::new(PlainSession))
2640            }
2641        }
2642
2643        // Client warehouse wins over a server default.
2644        let mut server = Server::new_async().await;
2645        let config_mock = server
2646            .mock("GET", "/v1/config")
2647            .match_query(mockito::Matcher::UrlEncoded(
2648                "warehouse".to_string(),
2649                "client-wh".to_string(),
2650            ))
2651            .with_status(200)
2652            .with_body(r#"{"defaults": {"warehouse": "default-wh"}, "overrides": {}}"#)
2653            .create_async()
2654            .await;
2655        let captured = Arc::new(AsyncMutex::new(None));
2656        let catalog = RestCatalog::new(
2657            SessionContext::empty(),
2658            RestCatalogConfig::builder()
2659                .uri(server.url())
2660                .warehouse("client-wh".to_string())
2661                .build(),
2662            Some(Arc::new(CapturingManager(captured.clone()))),
2663            Some(Arc::new(LocalFsStorageFactory)),
2664            Runtime::current(),
2665            None,
2666        );
2667        catalog.client().await.unwrap();
2668        config_mock.assert_async().await;
2669        let props = captured.lock().await.clone().unwrap();
2670        assert_eq!(
2671            props.get("warehouse").map(String::as_str),
2672            Some("client-wh")
2673        );
2674
2675        // A server override wins over the client warehouse.
2676        let mut server = Server::new_async().await;
2677        let config_mock = server
2678            .mock("GET", "/v1/config")
2679            .match_query(mockito::Matcher::UrlEncoded(
2680                "warehouse".to_string(),
2681                "client-wh".to_string(),
2682            ))
2683            .with_status(200)
2684            .with_body(r#"{"defaults": {}, "overrides": {"warehouse": "override-wh"}}"#)
2685            .create_async()
2686            .await;
2687        let captured = Arc::new(AsyncMutex::new(None));
2688        let catalog = RestCatalog::new(
2689            SessionContext::empty(),
2690            RestCatalogConfig::builder()
2691                .uri(server.url())
2692                .warehouse("client-wh".to_string())
2693                .build(),
2694            Some(Arc::new(CapturingManager(captured.clone()))),
2695            Some(Arc::new(LocalFsStorageFactory)),
2696            Runtime::current(),
2697            None,
2698        );
2699        catalog.client().await.unwrap();
2700        config_mock.assert_async().await;
2701        let props = captured.lock().await.clone().unwrap();
2702        assert_eq!(
2703            props.get("warehouse").map(String::as_str),
2704            Some("override-wh")
2705        );
2706    }
2707
2708    #[tokio::test]
2709    async fn test_init_session_dropped_before_catalog_session() {
2710        use std::sync::atomic::{AtomicBool, Ordering};
2711
2712        // A manager whose init session guards a one-shot resource (released on
2713        // drop) must see it released before `catalog_session` is invoked.
2714        #[derive(Debug)]
2715        struct GuardSession(Arc<AtomicBool>);
2716        impl Drop for GuardSession {
2717            fn drop(&mut self) {
2718                self.0.store(true, Ordering::SeqCst);
2719            }
2720        }
2721        #[async_trait]
2722        impl AuthSession for GuardSession {
2723            async fn authenticate(&self, _request: &mut HttpRequest) -> Result<()> {
2724                Ok(())
2725            }
2726        }
2727
2728        #[derive(Debug)]
2729        struct PlainSession;
2730        #[async_trait]
2731        impl AuthSession for PlainSession {
2732            async fn authenticate(&self, _request: &mut HttpRequest) -> Result<()> {
2733                Ok(())
2734            }
2735        }
2736
2737        #[derive(Debug)]
2738        struct GuardManager(Arc<AtomicBool>);
2739        #[async_trait]
2740        impl AuthManager for GuardManager {
2741            async fn init_session(
2742                &self,
2743                _client: &HttpClient,
2744                _props: &HashMap<String, String>,
2745            ) -> Result<Box<dyn AuthSession>> {
2746                Ok(Box::new(GuardSession(self.0.clone())))
2747            }
2748            async fn catalog_session(
2749                &self,
2750                _client: &HttpClient,
2751                _props: &HashMap<String, String>,
2752            ) -> Result<Arc<dyn AuthSession>> {
2753                if !self.0.load(Ordering::SeqCst) {
2754                    return Err(Error::new(
2755                        ErrorKind::Unexpected,
2756                        "init session must be dropped before catalog_session",
2757                    ));
2758                }
2759                Ok(Arc::new(PlainSession))
2760            }
2761        }
2762
2763        let mut server = Server::new_async().await;
2764        let config_mock = create_config_mock(&mut server).await;
2765
2766        let dropped = Arc::new(AtomicBool::new(false));
2767        let catalog = RestCatalog::new(
2768            SessionContext::empty(),
2769            RestCatalogConfig::builder().uri(server.url()).build(),
2770            Some(Arc::new(GuardManager(dropped.clone()))),
2771            Some(Arc::new(LocalFsStorageFactory)),
2772            Runtime::current(),
2773            None,
2774        );
2775
2776        catalog.client().await.unwrap();
2777        config_mock.assert_async().await;
2778        assert!(dropped.load(Ordering::SeqCst));
2779    }
2780
2781    #[test]
2782    fn test_config_debug_redacts_secrets() {
2783        let config = RestCatalogConfig::builder()
2784            .uri("http://localhost".to_string())
2785            .props(HashMap::from([
2786                ("token".to_string(), "tok-secret".to_string()),
2787                ("credential".to_string(), "id:cred-secret".to_string()),
2788                ("header.authorization".to_string(), "Basic xyz".to_string()),
2789                ("adls.account-key".to_string(), "adls-secret".to_string()),
2790                ("s3.sse.key".to_string(), "sse-secret".to_string()),
2791                (
2792                    "adls.connection-string".to_string(),
2793                    "cs-secret".to_string(),
2794                ),
2795                ("warehouse".to_string(), "wh1".to_string()),
2796            ]))
2797            .build();
2798
2799        let out = format!("{config:?}");
2800        assert!(!out.contains("tok-secret"));
2801        assert!(!out.contains("cred-secret"));
2802        assert!(!out.contains("Basic xyz"));
2803        assert!(!out.contains("adls-secret"));
2804        assert!(!out.contains("sse-secret"));
2805        assert!(!out.contains("cs-secret"));
2806        assert!(out.contains("[REDACTED]"));
2807        assert!(out.contains("wh1"));
2808    }
2809
2810    #[tokio::test]
2811    async fn test_auth_type_defaults() {
2812        // Unset `rest.auth.type`: `oauth2` when any OAuth material is
2813        // configured (existing setups keep working), `none` otherwise.
2814        let bare = RestCatalogConfig::builder()
2815            .uri("http://localhost".to_string())
2816            .build();
2817        assert!(
2818            format!("{:?}", test_catalog(bare).resolve_auth_manager().unwrap())
2819                .contains("NoopAuthManager")
2820        );
2821
2822        let with_token = RestCatalogConfig::builder()
2823            .uri("http://localhost".to_string())
2824            .props(HashMap::from([("token".to_string(), "tok".to_string())]))
2825            .build();
2826        assert!(
2827            format!(
2828                "{:?}",
2829                test_catalog(with_token).resolve_auth_manager().unwrap()
2830            )
2831            .contains("OAuth2Manager")
2832        );
2833
2834        // An explicit type is matched case-insensitively.
2835        let mixed_case = RestCatalogConfig::builder()
2836            .uri("http://localhost".to_string())
2837            .props(HashMap::from([(
2838                REST_CATALOG_PROP_AUTH_TYPE.to_string(),
2839                "OAuth2".to_string(),
2840            )]))
2841            .build();
2842        assert!(
2843            format!(
2844                "{:?}",
2845                test_catalog(mixed_case).resolve_auth_manager().unwrap()
2846            )
2847            .contains("OAuth2Manager")
2848        );
2849
2850        // An explicit OAuth endpoint is oauth2 intent too: the manager can
2851        // still pick up a server-supplied token from `/v1/config`.
2852        let with_endpoint = RestCatalogConfig::builder()
2853            .uri("http://localhost".to_string())
2854            .props(HashMap::from([(
2855                "oauth2-server-uri".to_string(),
2856                "http://auth.example.com/tokens".to_string(),
2857            )]))
2858            .build();
2859        assert!(
2860            format!(
2861                "{:?}",
2862                test_catalog(with_endpoint).resolve_auth_manager().unwrap()
2863            )
2864            .contains("OAuth2Manager")
2865        );
2866    }
2867
2868    #[tokio::test]
2869    async fn test_unknown_auth_type_is_rejected() {
2870        let props = HashMap::from([(
2871            REST_CATALOG_PROP_AUTH_TYPE.to_string(),
2872            "kerberos".to_string(),
2873        )]);
2874        let config = RestCatalogConfig::builder()
2875            .uri("http://localhost".to_string())
2876            .props(props)
2877            .build();
2878
2879        let err = test_catalog(config).resolve_auth_manager().unwrap_err();
2880        assert!(err.message().contains(REST_CATALOG_PROP_AUTH_TYPE));
2881    }
2882
2883    #[tokio::test]
2884    async fn test_with_auth_manager_overrides_config() {
2885        // A custom auth manager takes precedence over `rest.auth.type`.
2886        #[derive(Debug)]
2887        struct StubAuthManager;
2888        #[async_trait]
2889        impl AuthManager for StubAuthManager {
2890            async fn init_session(
2891                &self,
2892                _client: &HttpClient,
2893                _props: &HashMap<String, String>,
2894            ) -> Result<Box<dyn AuthSession>> {
2895                unimplemented!()
2896            }
2897            async fn catalog_session(
2898                &self,
2899                _client: &HttpClient,
2900                _props: &HashMap<String, String>,
2901            ) -> Result<Arc<dyn AuthSession>> {
2902                unimplemented!()
2903            }
2904        }
2905
2906        let config = RestCatalogConfig::builder()
2907            .uri("http://localhost".to_string())
2908            .props(HashMap::from([(
2909                REST_CATALOG_PROP_AUTH_TYPE.to_string(),
2910                "kerberos".to_string(),
2911            )]))
2912            .build();
2913
2914        // The unknown auth type is never consulted.
2915        let catalog = test_catalog_with(config, Some(Arc::new(StubAuthManager)));
2916        assert!(catalog.resolve_auth_manager().is_ok());
2917    }
2918
2919    #[tokio::test]
2920    async fn test_list_namespace_with_pagination() {
2921        let mut server = Server::new_async().await;
2922
2923        let config_mock = create_config_mock(&mut server).await;
2924
2925        let list_ns_mock_page1 = server
2926            .mock("GET", "/v1/namespaces")
2927            .with_body(
2928                r#"{
2929                "namespaces": [
2930                    ["ns1", "ns11"],
2931                    ["ns2"]
2932                ],
2933                "next-page-token": "token123"
2934            }"#,
2935            )
2936            .create_async()
2937            .await;
2938
2939        let list_ns_mock_page2 = server
2940            .mock("GET", "/v1/namespaces?pageToken=token123")
2941            .with_body(
2942                r#"{
2943                "namespaces": [
2944                    ["ns3"],
2945                    ["ns4", "ns41"]
2946                ]
2947            }"#,
2948            )
2949            .create_async()
2950            .await;
2951
2952        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
2953
2954        let namespaces = catalog
2955            .list_namespaces(&SessionContext::empty(), None)
2956            .await
2957            .unwrap();
2958
2959        let expected_ns = vec![
2960            NamespaceIdent::from_vec(vec!["ns1".to_string(), "ns11".to_string()]).unwrap(),
2961            NamespaceIdent::from_vec(vec!["ns2".to_string()]).unwrap(),
2962            NamespaceIdent::from_vec(vec!["ns3".to_string()]).unwrap(),
2963            NamespaceIdent::from_vec(vec!["ns4".to_string(), "ns41".to_string()]).unwrap(),
2964        ];
2965
2966        assert_eq!(expected_ns, namespaces);
2967
2968        config_mock.assert_async().await;
2969        list_ns_mock_page1.assert_async().await;
2970        list_ns_mock_page2.assert_async().await;
2971    }
2972
2973    #[tokio::test]
2974    async fn test_list_namespace_with_multiple_pages() {
2975        let mut server = Server::new_async().await;
2976
2977        let config_mock = create_config_mock(&mut server).await;
2978
2979        // Page 1
2980        let list_ns_mock_page1 = server
2981            .mock("GET", "/v1/namespaces")
2982            .with_body(
2983                r#"{
2984                "namespaces": [
2985                    ["ns1", "ns11"],
2986                    ["ns2"]
2987                ],
2988                "next-page-token": "page2"
2989            }"#,
2990            )
2991            .create_async()
2992            .await;
2993
2994        // Page 2
2995        let list_ns_mock_page2 = server
2996            .mock("GET", "/v1/namespaces?pageToken=page2")
2997            .with_body(
2998                r#"{
2999                "namespaces": [
3000                    ["ns3"],
3001                    ["ns4", "ns41"]
3002                ],
3003                "next-page-token": "page3"
3004            }"#,
3005            )
3006            .create_async()
3007            .await;
3008
3009        // Page 3
3010        let list_ns_mock_page3 = server
3011            .mock("GET", "/v1/namespaces?pageToken=page3")
3012            .with_body(
3013                r#"{
3014                "namespaces": [
3015                    ["ns5", "ns51", "ns511"]
3016                ],
3017                "next-page-token": "page4"
3018            }"#,
3019            )
3020            .create_async()
3021            .await;
3022
3023        // Page 4
3024        let list_ns_mock_page4 = server
3025            .mock("GET", "/v1/namespaces?pageToken=page4")
3026            .with_body(
3027                r#"{
3028                "namespaces": [
3029                    ["ns6"],
3030                    ["ns7"]
3031                ],
3032                "next-page-token": "page5"
3033            }"#,
3034            )
3035            .create_async()
3036            .await;
3037
3038        // Page 5 (final page)
3039        let list_ns_mock_page5 = server
3040            .mock("GET", "/v1/namespaces?pageToken=page5")
3041            .with_body(
3042                r#"{
3043                "namespaces": [
3044                    ["ns8", "ns81"]
3045                ]
3046            }"#,
3047            )
3048            .create_async()
3049            .await;
3050
3051        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3052
3053        let namespaces = catalog
3054            .list_namespaces(&SessionContext::empty(), None)
3055            .await
3056            .unwrap();
3057
3058        let expected_ns = vec![
3059            NamespaceIdent::from_vec(vec!["ns1".to_string(), "ns11".to_string()]).unwrap(),
3060            NamespaceIdent::from_vec(vec!["ns2".to_string()]).unwrap(),
3061            NamespaceIdent::from_vec(vec!["ns3".to_string()]).unwrap(),
3062            NamespaceIdent::from_vec(vec!["ns4".to_string(), "ns41".to_string()]).unwrap(),
3063            NamespaceIdent::from_vec(vec![
3064                "ns5".to_string(),
3065                "ns51".to_string(),
3066                "ns511".to_string(),
3067            ])
3068            .unwrap(),
3069            NamespaceIdent::from_vec(vec!["ns6".to_string()]).unwrap(),
3070            NamespaceIdent::from_vec(vec!["ns7".to_string()]).unwrap(),
3071            NamespaceIdent::from_vec(vec!["ns8".to_string(), "ns81".to_string()]).unwrap(),
3072        ];
3073
3074        assert_eq!(expected_ns, namespaces);
3075
3076        // Verify all page requests were made
3077        config_mock.assert_async().await;
3078        list_ns_mock_page1.assert_async().await;
3079        list_ns_mock_page2.assert_async().await;
3080        list_ns_mock_page3.assert_async().await;
3081        list_ns_mock_page4.assert_async().await;
3082        list_ns_mock_page5.assert_async().await;
3083    }
3084
3085    #[tokio::test]
3086    async fn test_create_namespace() {
3087        let mut server = Server::new_async().await;
3088
3089        let config_mock = create_config_mock(&mut server).await;
3090
3091        let create_ns_mock = server
3092            .mock("POST", "/v1/namespaces")
3093            .with_body(
3094                r#"{
3095                "namespace": [ "ns1", "ns11"],
3096                "properties" : {
3097                    "key1": "value1"
3098                }
3099            }"#,
3100            )
3101            .create_async()
3102            .await;
3103
3104        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3105
3106        let namespaces = catalog
3107            .create_namespace(
3108                &SessionContext::empty(),
3109                &NamespaceIdent::from_vec(vec!["ns1".to_string(), "ns11".to_string()]).unwrap(),
3110                HashMap::from([("key1".to_string(), "value1".to_string())]),
3111            )
3112            .await
3113            .unwrap();
3114
3115        let expected_ns = Namespace::with_properties(
3116            NamespaceIdent::from_vec(vec!["ns1".to_string(), "ns11".to_string()]).unwrap(),
3117            HashMap::from([("key1".to_string(), "value1".to_string())]),
3118        );
3119
3120        assert_eq!(expected_ns, namespaces);
3121
3122        config_mock.assert_async().await;
3123        create_ns_mock.assert_async().await;
3124    }
3125
3126    #[tokio::test]
3127    async fn test_get_namespace() {
3128        let mut server = Server::new_async().await;
3129
3130        let config_mock = create_config_mock(&mut server).await;
3131
3132        let get_ns_mock = server
3133            .mock("GET", "/v1/namespaces/ns1")
3134            .with_body(
3135                r#"{
3136                "namespace": [ "ns1"],
3137                "properties" : {
3138                    "key1": "value1"
3139                }
3140            }"#,
3141            )
3142            .create_async()
3143            .await;
3144
3145        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3146
3147        let namespaces = catalog
3148            .get_namespace(
3149                &SessionContext::empty(),
3150                &NamespaceIdent::new("ns1".to_string()),
3151            )
3152            .await
3153            .unwrap();
3154
3155        let expected_ns = Namespace::with_properties(
3156            NamespaceIdent::new("ns1".to_string()),
3157            HashMap::from([("key1".to_string(), "value1".to_string())]),
3158        );
3159
3160        assert_eq!(expected_ns, namespaces);
3161
3162        config_mock.assert_async().await;
3163        get_ns_mock.assert_async().await;
3164    }
3165
3166    #[tokio::test]
3167    async fn check_namespace_exists() {
3168        let mut server = Server::new_async().await;
3169
3170        let config_mock = create_config_mock_with_exists_endpoints(&mut server).await;
3171
3172        let get_ns_mock = server
3173            .mock("HEAD", "/v1/namespaces/ns1")
3174            .with_status(204)
3175            .create_async()
3176            .await;
3177
3178        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3179
3180        assert!(
3181            catalog
3182                .namespace_exists(
3183                    &SessionContext::empty(),
3184                    &NamespaceIdent::new("ns1".to_string())
3185                )
3186                .await
3187                .unwrap()
3188        );
3189
3190        config_mock.assert_async().await;
3191        get_ns_mock.assert_async().await;
3192    }
3193
3194    #[tokio::test]
3195    async fn test_namespace_exists_falls_back_to_get_when_head_not_advertised() {
3196        let mut server = Server::new_async().await;
3197
3198        // No `endpoints` advertised, and the default set has no HEAD namespace
3199        // route, so `namespace_exists` falls back to a GET load-namespace.
3200        let config_mock = create_config_mock(&mut server).await;
3201        let get_ns_mock = server
3202            .mock("GET", "/v1/namespaces/ns1")
3203            .with_status(200)
3204            .with_body(
3205                r#"{
3206                "namespace": ["ns1"],
3207                "properties": {}
3208            }"#,
3209            )
3210            .create_async()
3211            .await;
3212
3213        let catalog = RestCatalog::new(
3214            SessionContext::empty(),
3215            RestCatalogConfig::builder().uri(server.url()).build(),
3216            None,
3217            Some(Arc::new(LocalFsStorageFactory)),
3218            Runtime::current(),
3219            None,
3220        );
3221
3222        assert!(
3223            catalog
3224                .namespace_exists(&NamespaceIdent::new("ns1".to_string()))
3225                .await
3226                .unwrap()
3227        );
3228
3229        config_mock.assert_async().await;
3230        get_ns_mock.assert_async().await;
3231    }
3232
3233    #[tokio::test]
3234    async fn test_drop_namespace() {
3235        let mut server = Server::new_async().await;
3236
3237        let config_mock = create_config_mock(&mut server).await;
3238
3239        let drop_ns_mock = server
3240            .mock("DELETE", "/v1/namespaces/ns1")
3241            .with_status(204)
3242            .create_async()
3243            .await;
3244
3245        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3246
3247        catalog
3248            .drop_namespace(
3249                &SessionContext::empty(),
3250                &NamespaceIdent::new("ns1".to_string()),
3251            )
3252            .await
3253            .unwrap();
3254
3255        config_mock.assert_async().await;
3256        drop_ns_mock.assert_async().await;
3257    }
3258
3259    #[tokio::test]
3260    async fn test_list_tables() {
3261        let mut server = Server::new_async().await;
3262
3263        let config_mock = create_config_mock(&mut server).await;
3264
3265        let list_tables_mock = server
3266            .mock("GET", "/v1/namespaces/ns1/tables")
3267            .with_status(200)
3268            .with_body(
3269                r#"{
3270                "identifiers": [
3271                    {
3272                        "namespace": ["ns1"],
3273                        "name": "table1"
3274                    },
3275                    {
3276                        "namespace": ["ns1"],
3277                        "name": "table2"
3278                    }
3279                ]
3280            }"#,
3281            )
3282            .create_async()
3283            .await;
3284
3285        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3286
3287        let tables = catalog
3288            .list_tables(
3289                &SessionContext::empty(),
3290                &NamespaceIdent::new("ns1".to_string()),
3291            )
3292            .await
3293            .unwrap();
3294
3295        let expected_tables = vec![
3296            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string()),
3297            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table2".to_string()),
3298        ];
3299
3300        assert_eq!(tables, expected_tables);
3301
3302        config_mock.assert_async().await;
3303        list_tables_mock.assert_async().await;
3304    }
3305
3306    #[tokio::test]
3307    async fn test_list_tables_with_pagination() {
3308        let mut server = Server::new_async().await;
3309
3310        let config_mock = create_config_mock(&mut server).await;
3311
3312        let list_tables_mock_page1 = server
3313            .mock("GET", "/v1/namespaces/ns1/tables")
3314            .with_status(200)
3315            .with_body(
3316                r#"{
3317                "identifiers": [
3318                    {
3319                        "namespace": ["ns1"],
3320                        "name": "table1"
3321                    },
3322                    {
3323                        "namespace": ["ns1"],
3324                        "name": "table2"
3325                    }
3326                ],
3327                "next-page-token": "token456"
3328            }"#,
3329            )
3330            .create_async()
3331            .await;
3332
3333        let list_tables_mock_page2 = server
3334            .mock("GET", "/v1/namespaces/ns1/tables?pageToken=token456")
3335            .with_status(200)
3336            .with_body(
3337                r#"{
3338                "identifiers": [
3339                    {
3340                        "namespace": ["ns1"],
3341                        "name": "table3"
3342                    },
3343                    {
3344                        "namespace": ["ns1"],
3345                        "name": "table4"
3346                    }
3347                ]
3348            }"#,
3349            )
3350            .create_async()
3351            .await;
3352
3353        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3354
3355        let tables = catalog
3356            .list_tables(
3357                &SessionContext::empty(),
3358                &NamespaceIdent::new("ns1".to_string()),
3359            )
3360            .await
3361            .unwrap();
3362
3363        let expected_tables = vec![
3364            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string()),
3365            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table2".to_string()),
3366            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table3".to_string()),
3367            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table4".to_string()),
3368        ];
3369
3370        assert_eq!(tables, expected_tables);
3371
3372        config_mock.assert_async().await;
3373        list_tables_mock_page1.assert_async().await;
3374        list_tables_mock_page2.assert_async().await;
3375    }
3376
3377    #[tokio::test]
3378    async fn test_list_tables_with_multiple_pages() {
3379        let mut server = Server::new_async().await;
3380
3381        let config_mock = create_config_mock(&mut server).await;
3382
3383        // Page 1
3384        let list_tables_mock_page1 = server
3385            .mock("GET", "/v1/namespaces/ns1/tables")
3386            .with_status(200)
3387            .with_body(
3388                r#"{
3389                "identifiers": [
3390                    {
3391                        "namespace": ["ns1"],
3392                        "name": "table1"
3393                    },
3394                    {
3395                        "namespace": ["ns1"],
3396                        "name": "table2"
3397                    }
3398                ],
3399                "next-page-token": "page2"
3400            }"#,
3401            )
3402            .create_async()
3403            .await;
3404
3405        // Page 2
3406        let list_tables_mock_page2 = server
3407            .mock("GET", "/v1/namespaces/ns1/tables?pageToken=page2")
3408            .with_status(200)
3409            .with_body(
3410                r#"{
3411                "identifiers": [
3412                    {
3413                        "namespace": ["ns1"],
3414                        "name": "table3"
3415                    },
3416                    {
3417                        "namespace": ["ns1"],
3418                        "name": "table4"
3419                    }
3420                ],
3421                "next-page-token": "page3"
3422            }"#,
3423            )
3424            .create_async()
3425            .await;
3426
3427        // Page 3
3428        let list_tables_mock_page3 = server
3429            .mock("GET", "/v1/namespaces/ns1/tables?pageToken=page3")
3430            .with_status(200)
3431            .with_body(
3432                r#"{
3433                "identifiers": [
3434                    {
3435                        "namespace": ["ns1"],
3436                        "name": "table5"
3437                    }
3438                ],
3439                "next-page-token": "page4"
3440            }"#,
3441            )
3442            .create_async()
3443            .await;
3444
3445        // Page 4
3446        let list_tables_mock_page4 = server
3447            .mock("GET", "/v1/namespaces/ns1/tables?pageToken=page4")
3448            .with_status(200)
3449            .with_body(
3450                r#"{
3451                "identifiers": [
3452                    {
3453                        "namespace": ["ns1"],
3454                        "name": "table6"
3455                    },
3456                    {
3457                        "namespace": ["ns1"],
3458                        "name": "table7"
3459                    }
3460                ],
3461                "next-page-token": "page5"
3462            }"#,
3463            )
3464            .create_async()
3465            .await;
3466
3467        // Page 5 (final page)
3468        let list_tables_mock_page5 = server
3469            .mock("GET", "/v1/namespaces/ns1/tables?pageToken=page5")
3470            .with_status(200)
3471            .with_body(
3472                r#"{
3473                "identifiers": [
3474                    {
3475                        "namespace": ["ns1"],
3476                        "name": "table8"
3477                    }
3478                ]
3479            }"#,
3480            )
3481            .create_async()
3482            .await;
3483
3484        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3485
3486        let tables = catalog
3487            .list_tables(
3488                &SessionContext::empty(),
3489                &NamespaceIdent::new("ns1".to_string()),
3490            )
3491            .await
3492            .unwrap();
3493
3494        let expected_tables = vec![
3495            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string()),
3496            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table2".to_string()),
3497            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table3".to_string()),
3498            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table4".to_string()),
3499            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table5".to_string()),
3500            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table6".to_string()),
3501            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table7".to_string()),
3502            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table8".to_string()),
3503        ];
3504
3505        assert_eq!(tables, expected_tables);
3506
3507        // Verify all page requests were made
3508        config_mock.assert_async().await;
3509        list_tables_mock_page1.assert_async().await;
3510        list_tables_mock_page2.assert_async().await;
3511        list_tables_mock_page3.assert_async().await;
3512        list_tables_mock_page4.assert_async().await;
3513        list_tables_mock_page5.assert_async().await;
3514    }
3515
3516    #[tokio::test]
3517    async fn test_drop_tables() {
3518        let mut server = Server::new_async().await;
3519
3520        let config_mock = create_config_mock(&mut server).await;
3521
3522        let delete_table_mock = server
3523            .mock("DELETE", "/v1/namespaces/ns1/tables/table1")
3524            .with_status(204)
3525            .create_async()
3526            .await;
3527
3528        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3529
3530        catalog
3531            .drop_table(
3532                &SessionContext::empty(),
3533                &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string()),
3534            )
3535            .await
3536            .unwrap();
3537
3538        config_mock.assert_async().await;
3539        delete_table_mock.assert_async().await;
3540    }
3541
3542    #[tokio::test]
3543    async fn test_check_table_exists() {
3544        let mut server = Server::new_async().await;
3545
3546        let config_mock = create_config_mock_with_exists_endpoints(&mut server).await;
3547
3548        let check_table_exists_mock = server
3549            .mock("HEAD", "/v1/namespaces/ns1/tables/table1")
3550            .with_status(204)
3551            .create_async()
3552            .await;
3553
3554        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3555
3556        assert!(
3557            catalog
3558                .table_exists(
3559                    &SessionContext::empty(),
3560                    &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string(),),
3561                )
3562                .await
3563                .unwrap()
3564        );
3565
3566        config_mock.assert_async().await;
3567        check_table_exists_mock.assert_async().await;
3568    }
3569
3570    #[tokio::test]
3571    async fn test_table_exists_falls_back_to_load_when_head_not_advertised() {
3572        let mut server = Server::new_async().await;
3573
3574        // No `endpoints` advertised, and the default set has no HEAD table
3575        // route, so `table_exists` falls back to a GET load-table.
3576        let config_mock = create_config_mock(&mut server).await;
3577        let load_table_mock = server
3578            .mock("GET", "/v1/namespaces/ns1/tables/table1")
3579            .with_status(200)
3580            .with_body_from_file(format!(
3581                "{}/testdata/{}",
3582                env!("CARGO_MANIFEST_DIR"),
3583                "load_table_response.json"
3584            ))
3585            .create_async()
3586            .await;
3587
3588        let catalog = RestCatalog::new(
3589            SessionContext::empty(),
3590            RestCatalogConfig::builder().uri(server.url()).build(),
3591            None,
3592            Some(Arc::new(LocalFsStorageFactory)),
3593            Runtime::current(),
3594            None,
3595        );
3596
3597        assert!(
3598            catalog
3599                .table_exists(&TableIdent::new(
3600                    NamespaceIdent::new("ns1".to_string()),
3601                    "table1".to_string(),
3602                ))
3603                .await
3604                .unwrap()
3605        );
3606
3607        config_mock.assert_async().await;
3608        load_table_mock.assert_async().await;
3609    }
3610
3611    #[tokio::test]
3612    async fn test_rename_table() {
3613        let mut server = Server::new_async().await;
3614
3615        let config_mock = create_config_mock(&mut server).await;
3616
3617        let rename_table_mock = server
3618            .mock("POST", "/v1/tables/rename")
3619            .with_status(204)
3620            .create_async()
3621            .await;
3622
3623        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3624
3625        catalog
3626            .rename_table(
3627                &SessionContext::empty(),
3628                &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table1".to_string()),
3629                &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "table2".to_string()),
3630            )
3631            .await
3632            .unwrap();
3633
3634        config_mock.assert_async().await;
3635        rename_table_mock.assert_async().await;
3636    }
3637
3638    #[tokio::test]
3639    async fn test_load_table() {
3640        let mut server = Server::new_async().await;
3641
3642        let config_mock = create_config_mock(&mut server).await;
3643
3644        let rename_table_mock = server
3645            .mock("GET", "/v1/namespaces/ns1/tables/test1")
3646            .with_status(200)
3647            .with_body_from_file(format!(
3648                "{}/testdata/{}",
3649                env!("CARGO_MANIFEST_DIR"),
3650                "load_table_response.json"
3651            ))
3652            .create_async()
3653            .await;
3654
3655        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3656
3657        let table = catalog
3658            .load_table(
3659                &SessionContext::empty(),
3660                &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "test1".to_string()),
3661            )
3662            .await
3663            .unwrap();
3664
3665        assert_eq!(
3666            &TableIdent::from_strs(vec!["ns1", "test1"]).unwrap(),
3667            table.identifier()
3668        );
3669        assert_eq!(
3670            "s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
3671            table.metadata_location().unwrap()
3672        );
3673        assert_eq!(FormatVersion::V1, table.metadata().format_version());
3674        assert_eq!("s3://warehouse/database/table", table.metadata().location());
3675        assert_eq!(
3676            uuid!("b55d9dda-6561-423a-8bfc-787980ce421f"),
3677            table.metadata().uuid()
3678        );
3679        assert_eq!(
3680            Utc.timestamp_millis_opt(1646787054459).unwrap(),
3681            table.metadata().last_updated_timestamp().unwrap()
3682        );
3683        assert_eq!(
3684            vec![&Arc::new(
3685                Schema::builder()
3686                    .with_fields(vec![
3687                        NestedField::optional(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
3688                        NestedField::optional(2, "data", Type::Primitive(PrimitiveType::String))
3689                            .into(),
3690                    ])
3691                    .build()
3692                    .unwrap()
3693            )],
3694            table.metadata().schemas_iter().collect::<Vec<_>>()
3695        );
3696        assert_eq!(
3697            &HashMap::from([
3698                ("owner".to_string(), "bryan".to_string()),
3699                (
3700                    "write.metadata.compression-codec".to_string(),
3701                    "gzip".to_string()
3702                )
3703            ]),
3704            table.metadata().properties()
3705        );
3706        assert_eq!(vec![&Arc::new(Snapshot::builder()
3707            .with_snapshot_id(3497810964824022504)
3708            .with_timestamp_ms(1646787054459)
3709            .with_manifest_list("s3://warehouse/database/table/metadata/snap-3497810964824022504-1-c4f68204-666b-4e50-a9df-b10c34bf6b82.avro")
3710            .with_sequence_number(0)
3711            .with_schema_id(0)
3712            .with_summary(Summary {
3713                operation: Operation::Append,
3714                additional_properties: HashMap::from_iter([
3715                    ("spark.app.id", "local-1646787004168"),
3716                    ("added-data-files", "1"),
3717                    ("added-records", "1"),
3718                    ("added-files-size", "697"),
3719                    ("changed-partition-count", "1"),
3720                    ("total-records", "1"),
3721                    ("total-files-size", "697"),
3722                    ("total-data-files", "1"),
3723                    ("total-delete-files", "0"),
3724                    ("total-position-deletes", "0"),
3725                    ("total-equality-deletes", "0")
3726                ].iter().map(|p| (p.0.to_string(), p.1.to_string()))),
3727            }).build()
3728        )], table.metadata().snapshots().collect::<Vec<_>>());
3729        assert_eq!(
3730            &[SnapshotLog {
3731                timestamp_ms: 1646787054459,
3732                snapshot_id: 3497810964824022504,
3733            }],
3734            table.metadata().history()
3735        );
3736        assert_eq!(
3737            vec![&Arc::new(SortOrder {
3738                order_id: 0,
3739                fields: vec![],
3740            })],
3741            table.metadata().sort_orders_iter().collect::<Vec<_>>()
3742        );
3743
3744        config_mock.assert_async().await;
3745        rename_table_mock.assert_async().await;
3746    }
3747
3748    #[tokio::test]
3749    async fn test_load_table_404() {
3750        let mut server = Server::new_async().await;
3751
3752        let config_mock = create_config_mock(&mut server).await;
3753
3754        let rename_table_mock = server
3755            .mock("GET", "/v1/namespaces/ns1/tables/test1")
3756            .with_status(404)
3757            .with_body(r#"
3758{
3759    "error": {
3760        "message": "Table does not exist: ns1.test1 in warehouse 8bcb0838-50fc-472d-9ddb-8feb89ef5f1e",
3761        "type": "NoSuchNamespaceErrorException",
3762        "code": 404
3763    }
3764}
3765            "#)
3766            .create_async()
3767            .await;
3768
3769        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3770
3771        let table = catalog
3772            .load_table(
3773                &SessionContext::empty(),
3774                &TableIdent::new(NamespaceIdent::new("ns1".to_string()), "test1".to_string()),
3775            )
3776            .await;
3777
3778        assert!(table.is_err());
3779        assert!(table.err().unwrap().message().contains("does not exist"));
3780
3781        config_mock.assert_async().await;
3782        rename_table_mock.assert_async().await;
3783    }
3784
3785    #[tokio::test]
3786    async fn test_create_table() {
3787        let mut server = Server::new_async().await;
3788
3789        let config_mock = create_config_mock(&mut server).await;
3790
3791        let create_table_mock = server
3792            .mock("POST", "/v1/namespaces/ns1/tables")
3793            .with_status(200)
3794            .with_body_from_file(format!(
3795                "{}/testdata/{}",
3796                env!("CARGO_MANIFEST_DIR"),
3797                "create_table_response.json"
3798            ))
3799            .create_async()
3800            .await;
3801
3802        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3803
3804        let table_creation = TableCreation::builder()
3805            .name("test1".to_string())
3806            .schema(
3807                Schema::builder()
3808                    .with_fields(vec![
3809                        NestedField::optional(1, "foo", Type::Primitive(PrimitiveType::String))
3810                            .into(),
3811                        NestedField::required(2, "bar", Type::Primitive(PrimitiveType::Int)).into(),
3812                        NestedField::optional(3, "baz", Type::Primitive(PrimitiveType::Boolean))
3813                            .into(),
3814                    ])
3815                    .with_schema_id(1)
3816                    .with_identifier_field_ids(vec![2])
3817                    .build()
3818                    .unwrap(),
3819            )
3820            .properties(HashMap::from([("owner".to_string(), "testx".to_string())]))
3821            .partition_spec(
3822                UnboundPartitionSpec::builder()
3823                    .add_partition_fields(vec![
3824                        UnboundPartitionField::builder()
3825                            .source_id(1)
3826                            .transform(Transform::Truncate(3))
3827                            .name("id".to_string())
3828                            .build(),
3829                    ])
3830                    .unwrap()
3831                    .build(),
3832            )
3833            .sort_order(
3834                SortOrder::builder()
3835                    .with_sort_field(
3836                        SortField::builder()
3837                            .source_id(2)
3838                            .transform(Transform::Identity)
3839                            .direction(SortDirection::Ascending)
3840                            .null_order(NullOrder::First)
3841                            .build(),
3842                    )
3843                    .build_unbound()
3844                    .unwrap(),
3845            )
3846            .build();
3847
3848        let table = catalog
3849            .create_table(
3850                &SessionContext::empty(),
3851                &NamespaceIdent::from_strs(["ns1"]).unwrap(),
3852                table_creation,
3853            )
3854            .await
3855            .unwrap();
3856
3857        assert_eq!(
3858            &TableIdent::from_strs(vec!["ns1", "test1"]).unwrap(),
3859            table.identifier()
3860        );
3861        assert_eq!(
3862            "s3://warehouse/database/table/metadata.json",
3863            table.metadata_location().unwrap()
3864        );
3865        assert_eq!(FormatVersion::V1, table.metadata().format_version());
3866        assert_eq!("s3://warehouse/database/table", table.metadata().location());
3867        assert_eq!(
3868            uuid!("bf289591-dcc0-4234-ad4f-5c3eed811a29"),
3869            table.metadata().uuid()
3870        );
3871        assert_eq!(
3872            1657810967051,
3873            table
3874                .metadata()
3875                .last_updated_timestamp()
3876                .unwrap()
3877                .timestamp_millis()
3878        );
3879        assert_eq!(
3880            vec![&Arc::new(
3881                Schema::builder()
3882                    .with_fields(vec![
3883                        NestedField::optional(1, "foo", Type::Primitive(PrimitiveType::String))
3884                            .into(),
3885                        NestedField::required(2, "bar", Type::Primitive(PrimitiveType::Int)).into(),
3886                        NestedField::optional(3, "baz", Type::Primitive(PrimitiveType::Boolean))
3887                            .into(),
3888                    ])
3889                    .with_schema_id(0)
3890                    .with_identifier_field_ids(vec![2])
3891                    .build()
3892                    .unwrap()
3893            )],
3894            table.metadata().schemas_iter().collect::<Vec<_>>()
3895        );
3896        assert_eq!(
3897            &HashMap::from([
3898                (
3899                    "write.delete.parquet.compression-codec".to_string(),
3900                    "zstd".to_string()
3901                ),
3902                (
3903                    "write.metadata.compression-codec".to_string(),
3904                    "gzip".to_string()
3905                ),
3906                (
3907                    "write.summary.partition-limit".to_string(),
3908                    "100".to_string()
3909                ),
3910                (
3911                    "write.parquet.compression-codec".to_string(),
3912                    "zstd".to_string()
3913                ),
3914            ]),
3915            table.metadata().properties()
3916        );
3917        assert!(table.metadata().current_snapshot().is_none());
3918        assert!(table.metadata().history().is_empty());
3919        assert_eq!(
3920            vec![&Arc::new(SortOrder {
3921                order_id: 0,
3922                fields: vec![],
3923            })],
3924            table.metadata().sort_orders_iter().collect::<Vec<_>>()
3925        );
3926
3927        config_mock.assert_async().await;
3928        create_table_mock.assert_async().await;
3929    }
3930
3931    #[tokio::test]
3932    async fn test_create_table_409() {
3933        let mut server = Server::new_async().await;
3934
3935        let config_mock = create_config_mock(&mut server).await;
3936
3937        let create_table_mock = server
3938            .mock("POST", "/v1/namespaces/ns1/tables")
3939            .with_status(409)
3940            .with_body(r#"
3941{
3942    "error": {
3943        "message": "Table already exists: ns1.test1 in warehouse 8bcb0838-50fc-472d-9ddb-8feb89ef5f1e",
3944        "type": "AlreadyExistsException",
3945        "code": 409
3946    }
3947}
3948            "#)
3949            .create_async()
3950            .await;
3951
3952        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
3953
3954        let table_creation = TableCreation::builder()
3955            .name("test1".to_string())
3956            .schema(
3957                Schema::builder()
3958                    .with_fields(vec![
3959                        NestedField::optional(1, "foo", Type::Primitive(PrimitiveType::String))
3960                            .into(),
3961                        NestedField::required(2, "bar", Type::Primitive(PrimitiveType::Int)).into(),
3962                        NestedField::optional(3, "baz", Type::Primitive(PrimitiveType::Boolean))
3963                            .into(),
3964                    ])
3965                    .with_schema_id(1)
3966                    .with_identifier_field_ids(vec![2])
3967                    .build()
3968                    .unwrap(),
3969            )
3970            .properties(HashMap::from([("owner".to_string(), "testx".to_string())]))
3971            .build();
3972
3973        let table_result = catalog
3974            .create_table(
3975                &SessionContext::empty(),
3976                &NamespaceIdent::from_strs(["ns1"]).unwrap(),
3977                table_creation,
3978            )
3979            .await;
3980
3981        assert!(table_result.is_err());
3982        assert!(
3983            table_result
3984                .err()
3985                .unwrap()
3986                .message()
3987                .contains("already exists")
3988        );
3989
3990        config_mock.assert_async().await;
3991        create_table_mock.assert_async().await;
3992    }
3993
3994    #[tokio::test]
3995    async fn test_update_table() {
3996        let mut server = Server::new_async().await;
3997
3998        let config_mock = create_config_mock(&mut server).await;
3999
4000        let load_table_mock = server
4001            .mock("GET", "/v1/namespaces/ns1/tables/test1")
4002            .with_status(200)
4003            .with_body_from_file(format!(
4004                "{}/testdata/{}",
4005                env!("CARGO_MANIFEST_DIR"),
4006                "load_table_response.json"
4007            ))
4008            .create_async()
4009            .await;
4010
4011        let update_table_mock = server
4012            .mock("POST", "/v1/namespaces/ns1/tables/test1")
4013            .with_status(200)
4014            .with_body_from_file(format!(
4015                "{}/testdata/{}",
4016                env!("CARGO_MANIFEST_DIR"),
4017                "update_table_response.json"
4018            ))
4019            .create_async()
4020            .await;
4021
4022        let catalog = RestCatalog::new(
4023            SessionContext::empty(),
4024            RestCatalogConfig::builder().uri(server.url()).build(),
4025            None,
4026            Some(Arc::new(LocalFsStorageFactory)),
4027            Runtime::current(),
4028            None,
4029        );
4030
4031        let table1 = {
4032            let file = File::open(format!(
4033                "{}/testdata/{}",
4034                env!("CARGO_MANIFEST_DIR"),
4035                "create_table_response.json"
4036            ))
4037            .unwrap();
4038            let reader = BufReader::new(file);
4039            let resp = serde_json::from_reader::<_, LoadTableResult>(reader).unwrap();
4040
4041            Table::builder()
4042                .metadata(resp.metadata)
4043                .metadata_location(resp.metadata_location.unwrap())
4044                .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
4045                .file_io(FileIO::new_with_fs())
4046                .runtime(test_runtime())
4047                .build()
4048                .unwrap()
4049        };
4050
4051        let tx = Transaction::new(&table1);
4052        let table = tx
4053            .upgrade_table_version()
4054            .set_format_version(FormatVersion::V2)
4055            .apply(tx)
4056            .unwrap()
4057            .commit(&catalog)
4058            .await
4059            .unwrap();
4060
4061        assert_eq!(
4062            &TableIdent::from_strs(vec!["ns1", "test1"]).unwrap(),
4063            table.identifier()
4064        );
4065        assert_eq!(
4066            "s3://warehouse/database/table/metadata.json",
4067            table.metadata_location().unwrap()
4068        );
4069        assert_eq!(FormatVersion::V2, table.metadata().format_version());
4070        assert_eq!("s3://warehouse/database/table", table.metadata().location());
4071        assert_eq!(
4072            uuid!("bf289591-dcc0-4234-ad4f-5c3eed811a29"),
4073            table.metadata().uuid()
4074        );
4075        assert_eq!(
4076            1657810967051,
4077            table
4078                .metadata()
4079                .last_updated_timestamp()
4080                .unwrap()
4081                .timestamp_millis()
4082        );
4083        assert_eq!(
4084            vec![&Arc::new(
4085                Schema::builder()
4086                    .with_fields(vec![
4087                        NestedField::optional(1, "foo", Type::Primitive(PrimitiveType::String))
4088                            .into(),
4089                        NestedField::required(2, "bar", Type::Primitive(PrimitiveType::Int)).into(),
4090                        NestedField::optional(3, "baz", Type::Primitive(PrimitiveType::Boolean))
4091                            .into(),
4092                    ])
4093                    .with_schema_id(0)
4094                    .with_identifier_field_ids(vec![2])
4095                    .build()
4096                    .unwrap()
4097            )],
4098            table.metadata().schemas_iter().collect::<Vec<_>>()
4099        );
4100        assert_eq!(
4101            &HashMap::from([
4102                (
4103                    "write.delete.parquet.compression-codec".to_string(),
4104                    "zstd".to_string()
4105                ),
4106                (
4107                    "write.metadata.compression-codec".to_string(),
4108                    "gzip".to_string()
4109                ),
4110                (
4111                    "write.summary.partition-limit".to_string(),
4112                    "100".to_string()
4113                ),
4114                (
4115                    "write.parquet.compression-codec".to_string(),
4116                    "zstd".to_string()
4117                ),
4118            ]),
4119            table.metadata().properties()
4120        );
4121        assert!(table.metadata().current_snapshot().is_none());
4122        assert!(table.metadata().history().is_empty());
4123        assert_eq!(
4124            vec![&Arc::new(SortOrder {
4125                order_id: 0,
4126                fields: vec![],
4127            })],
4128            table.metadata().sort_orders_iter().collect::<Vec<_>>()
4129        );
4130
4131        config_mock.assert_async().await;
4132        update_table_mock.assert_async().await;
4133        load_table_mock.assert_async().await
4134    }
4135
4136    #[tokio::test]
4137    async fn test_update_table_404() {
4138        let mut server = Server::new_async().await;
4139
4140        let config_mock = create_config_mock(&mut server).await;
4141
4142        let load_table_mock = server
4143            .mock("GET", "/v1/namespaces/ns1/tables/test1")
4144            .with_status(200)
4145            .with_body_from_file(format!(
4146                "{}/testdata/{}",
4147                env!("CARGO_MANIFEST_DIR"),
4148                "load_table_response.json"
4149            ))
4150            .create_async()
4151            .await;
4152
4153        let update_table_mock = server
4154            .mock("POST", "/v1/namespaces/ns1/tables/test1")
4155            .with_status(404)
4156            .with_body(
4157                r#"
4158{
4159    "error": {
4160        "message": "The given table does not exist",
4161        "type": "NoSuchTableException",
4162        "code": 404
4163    }
4164}
4165            "#,
4166            )
4167            .create_async()
4168            .await;
4169
4170        let catalog = RestCatalog::new(
4171            SessionContext::empty(),
4172            RestCatalogConfig::builder().uri(server.url()).build(),
4173            None,
4174            Some(Arc::new(LocalFsStorageFactory)),
4175            Runtime::current(),
4176            None,
4177        );
4178
4179        let table1 = {
4180            let file = File::open(format!(
4181                "{}/testdata/{}",
4182                env!("CARGO_MANIFEST_DIR"),
4183                "create_table_response.json"
4184            ))
4185            .unwrap();
4186            let reader = BufReader::new(file);
4187            let resp = serde_json::from_reader::<_, LoadTableResult>(reader).unwrap();
4188
4189            Table::builder()
4190                .metadata(resp.metadata)
4191                .metadata_location(resp.metadata_location.unwrap())
4192                .identifier(TableIdent::from_strs(["ns1", "test1"]).unwrap())
4193                .file_io(FileIO::new_with_fs())
4194                .runtime(test_runtime())
4195                .build()
4196                .unwrap()
4197        };
4198
4199        let tx = Transaction::new(&table1);
4200        let table_result = tx
4201            .upgrade_table_version()
4202            .set_format_version(FormatVersion::V2)
4203            .apply(tx)
4204            .unwrap()
4205            .commit(&catalog)
4206            .await;
4207
4208        assert!(table_result.is_err());
4209        assert!(
4210            table_result
4211                .err()
4212                .unwrap()
4213                .message()
4214                .contains("does not exist")
4215        );
4216
4217        config_mock.assert_async().await;
4218        update_table_mock.assert_async().await;
4219        load_table_mock.assert_async().await;
4220    }
4221
4222    #[tokio::test]
4223    async fn test_register_table() {
4224        let mut server = Server::new_async().await;
4225
4226        let config_mock = create_config_mock(&mut server).await;
4227
4228        let register_table_mock = server
4229            .mock("POST", "/v1/namespaces/ns1/register")
4230            .with_status(200)
4231            .with_body_from_file(format!(
4232                "{}/testdata/{}",
4233                env!("CARGO_MANIFEST_DIR"),
4234                "load_table_response.json"
4235            ))
4236            .create_async()
4237            .await;
4238
4239        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
4240        let table_ident =
4241            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "test1".to_string());
4242        let metadata_location = String::from(
4243            "s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
4244        );
4245
4246        let table = catalog
4247            .register_table(&SessionContext::empty(), &table_ident, metadata_location)
4248            .await
4249            .unwrap();
4250
4251        assert_eq!(
4252            &TableIdent::from_strs(vec!["ns1", "test1"]).unwrap(),
4253            table.identifier()
4254        );
4255        assert_eq!(
4256            "s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
4257            table.metadata_location().unwrap()
4258        );
4259
4260        config_mock.assert_async().await;
4261        register_table_mock.assert_async().await;
4262    }
4263
4264    #[tokio::test]
4265    async fn test_register_table_404() {
4266        let mut server = Server::new_async().await;
4267
4268        let config_mock = create_config_mock(&mut server).await;
4269
4270        let register_table_mock = server
4271            .mock("POST", "/v1/namespaces/ns1/register")
4272            .with_status(404)
4273            .with_body(
4274                r#"
4275{
4276    "error": {
4277        "message": "The namespace specified does not exist",
4278        "type": "NoSuchNamespaceErrorException",
4279        "code": 404
4280    }
4281}
4282            "#,
4283            )
4284            .create_async()
4285            .await;
4286
4287        let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
4288
4289        let table_ident =
4290            TableIdent::new(NamespaceIdent::new("ns1".to_string()), "test1".to_string());
4291        let metadata_location = String::from(
4292            "s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
4293        );
4294        let table = catalog
4295            .register_table(&SessionContext::empty(), &table_ident, metadata_location)
4296            .await;
4297
4298        assert!(table.is_err());
4299        assert!(table.err().unwrap().message().contains("does not exist"));
4300
4301        config_mock.assert_async().await;
4302        register_table_mock.assert_async().await;
4303    }
4304
4305    #[tokio::test]
4306    async fn test_create_rest_catalog() {
4307        let builder = RestCatalogBuilder::default().with_client(Client::new());
4308
4309        let catalog = builder
4310            .load(
4311                "test",
4312                HashMap::from([
4313                    (
4314                        REST_CATALOG_PROP_URI.to_string(),
4315                        "http://localhost:8080".to_string(),
4316                    ),
4317                    ("a".to_string(), "b".to_string()),
4318                ]),
4319            )
4320            .await;
4321
4322        assert!(catalog.is_ok());
4323
4324        let catalog = catalog.unwrap();
4325        let catalog_config = &catalog.inner.user_config;
4326        assert_eq!(catalog_config.name.as_deref(), Some("test"));
4327        assert_eq!(catalog_config.uri, "http://localhost:8080");
4328        assert_eq!(catalog_config.warehouse, None);
4329        assert!(catalog_config.client.is_some());
4330
4331        assert_eq!(catalog_config.props.get("a"), Some(&"b".to_string()));
4332        assert!(!catalog_config.props.contains_key(REST_CATALOG_PROP_URI));
4333    }
4334
4335    #[tokio::test]
4336    async fn test_create_rest_catalog_no_uri() {
4337        let builder = RestCatalogBuilder::default();
4338
4339        let catalog = builder
4340            .load(
4341                "test",
4342                HashMap::from([(
4343                    REST_CATALOG_PROP_WAREHOUSE.to_string(),
4344                    "s3://warehouse".to_string(),
4345                )]),
4346            )
4347            .await;
4348
4349        assert!(catalog.is_err());
4350        if let Err(err) = catalog {
4351            assert_eq!(err.kind(), ErrorKind::DataInvalid);
4352            assert_eq!(err.message(), "Catalog uri is required");
4353        }
4354    }
4355
4356    #[tokio::test]
4357    async fn test_create_session_catalog() {
4358        let builder = RestSessionCatalogBuilder::default();
4359
4360        let result = builder
4361            .load(
4362                "test",
4363                HashMap::from([
4364                    (
4365                        REST_CATALOG_PROP_URI.to_string(),
4366                        "http://localhost:8080".to_string(),
4367                    ),
4368                    ("a".to_string(), "b".to_string()),
4369                ]),
4370            )
4371            .await;
4372
4373        assert!(result.is_ok());
4374
4375        let catalog = result.unwrap();
4376
4377        let catalog_config = catalog.user_config;
4378        assert_eq!(catalog_config.name.as_deref(), Some("test"));
4379        assert_eq!(catalog_config.uri, "http://localhost:8080");
4380        assert_eq!(catalog_config.warehouse, None);
4381        // The default builder sets no client (only `with_client` does).
4382        assert!(catalog_config.client.is_none());
4383
4384        // `uri` is consumed into its own field; other props are retained.
4385        assert_eq!(catalog_config.props.get("a"), Some(&"b".to_string()));
4386        assert!(!catalog_config.props.contains_key(REST_CATALOG_PROP_URI));
4387    }
4388
4389    #[tokio::test]
4390    async fn test_create_rest_catalog_with_session() {
4391        let context = SessionContext::builder()
4392            .session_id("test-id".to_string())
4393            .build();
4394
4395        let result = RestCatalogBuilder::default()
4396            .with_session_context(context)
4397            .load(
4398                "test",
4399                HashMap::from([(
4400                    REST_CATALOG_PROP_URI.to_string(),
4401                    "http://localhost:8080".to_string(),
4402                )]),
4403            )
4404            .await;
4405
4406        assert!(result.is_ok());
4407
4408        // The context passed to `with_session_context` is the one the catalog is bound to.
4409        let catalog = result.unwrap();
4410        assert_eq!(catalog.session_context.session_id(), "test-id");
4411    }
4412
4413    #[tokio::test]
4414    async fn test_create_rest_catalog_default_session() {
4415        let result = RestCatalogBuilder::default()
4416            .load(
4417                "test",
4418                HashMap::from([(
4419                    REST_CATALOG_PROP_URI.to_string(),
4420                    "http://localhost:8080".to_string(),
4421                )]),
4422            )
4423            .await;
4424
4425        assert!(result.is_ok());
4426
4427        // Without `with_session_context`, the catalog falls back to `SessionContext::empty()`,
4428        // which assigns a fresh v4 UUID.
4429        let catalog = result.unwrap();
4430        assert!(uuid::Uuid::parse_str(catalog.session_context.session_id()).is_ok());
4431    }
4432
4433    /// Smoke test: a [`Catalog`] trait method delegates through the façade to `RestSessionCatalog`.
4434    #[tokio::test]
4435    async fn test_rest_catalog_delegates() {
4436        let mut server = Server::new_async().await;
4437        let config_mock = create_config_mock(&mut server).await;
4438        let list_ns_mock = server
4439            .mock("GET", "/v1/namespaces")
4440            .with_body(
4441                r#"{
4442                "namespaces": [["ns1"]]
4443            }"#,
4444            )
4445            .create_async()
4446            .await;
4447
4448        let catalog = RestCatalog::new(
4449            SessionContext::empty(),
4450            RestCatalogConfig::builder().uri(server.url()).build(),
4451            None,
4452            Some(Arc::new(LocalFsStorageFactory)),
4453            Runtime::current(),
4454            None,
4455        );
4456
4457        let namespaces = catalog.list_namespaces(None).await.unwrap();
4458
4459        assert_eq!(namespaces, vec![
4460            NamespaceIdent::from_vec(vec!["ns1".to_string()]).unwrap()
4461        ]);
4462
4463        config_mock.assert_async().await;
4464        list_ns_mock.assert_async().await;
4465    }
4466}