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