Skip to main content

unitycatalog_client/
delta_v1.rs

1//! Hand-written client for the UC Delta REST API (`/delta/v1/...`).
2//!
3//! The Delta API is a standalone REST protocol (not a generated resource API),
4//! so — like [`crate::temporary_credentials`] — it is hand-maintained. The wire
5//! DTOs are shared with the server via
6//! [`unitycatalog_delta_api::models`]. This client covers the full
7//! `delta.yaml` surface: catalog config negotiation, the catalog-managed table
8//! lifecycle (`createStagingTable`, `createTable`, `loadTable`, `updateTable`,
9//! `deleteTable`, `tableExists`, `renameTable`), credential vending
10//! (`getTableCredentials`, `getStagingTableCredentials`,
11//! `getTemporaryPathCredentials`), and commit-metrics reporting (`reportMetrics`).
12
13use olai_http::CloudClient;
14use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
15use unitycatalog_delta_api::models::{
16    DeltaCatalogConfig, DeltaCreateStagingTableRequest, DeltaCreateTableRequest,
17    DeltaCredentialOperation, DeltaCredentialsResponse, DeltaLoadTableResponse,
18    DeltaRenameTableRequest, DeltaReportMetricsRequest, DeltaStagingTableResponse,
19    DeltaUpdateTableRequest,
20};
21use url::Url;
22
23use crate::Result;
24
25/// Percent-encode a single path segment so names containing `/ ? # space ..`
26/// route to the intended resource instead of being interpreted as path
27/// structure. We encode every non-alphanumeric byte (a superset of the RFC 3986
28/// path-segment reserved set), which is safe because each call encodes exactly
29/// one segment that is then joined with literal `/` separators.
30fn encode_segment(segment: &str) -> String {
31    utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
32}
33
34/// The wire string for a credential operation as used in `operation` query params.
35fn operation_param(operation: DeltaCredentialOperation) -> &'static str {
36    match operation {
37        DeltaCredentialOperation::Read => "READ",
38        DeltaCredentialOperation::ReadWrite => "READ_WRITE",
39    }
40}
41
42/// Client for the `/delta/v1/` Delta REST API.
43///
44/// Constructed from a [`CloudClient`] (carrying auth) and the Unity Catalog base
45/// URL. Prefer [`UnityCatalogClient::delta_v1`](crate::UnityCatalogClient::delta_v1).
46#[derive(Clone)]
47pub struct DeltaV1Client {
48    client: CloudClient,
49    base_url: Url,
50}
51
52impl DeltaV1Client {
53    /// Create a new Delta v1 client. `base_url` is normalized to end in `/` so it
54    /// joins cleanly with the relative endpoint paths.
55    pub fn new(client: CloudClient, mut base_url: Url) -> Self {
56        if !base_url.path().ends_with('/') {
57            base_url.set_path(&format!("{}/", base_url.path()));
58        }
59        Self { client, base_url }
60    }
61
62    /// Build a `/delta/v1/...` URL from already-encoded path components.
63    fn url(&self, rest: &str) -> Result<Url> {
64        Ok(self.base_url.join(&format!("delta/v1/{rest}"))?)
65    }
66
67    /// Get catalog configuration and supported endpoints —
68    /// `GET /delta/v1/config?catalog={catalog}&protocol-versions={versions}`.
69    ///
70    /// This is the protocol-negotiation entry point: the client advertises the
71    /// highest protocol versions it supports per major version (`protocol_versions`,
72    /// e.g. `"1.1,2.3"`) and the server returns the endpoints it supports for the
73    /// newest mutually supported version. Used to discover whether the
74    /// `/delta/v1` surface is available before falling back to the legacy API.
75    pub async fn get_config(
76        &self,
77        catalog: &str,
78        protocol_versions: &str,
79    ) -> Result<DeltaCatalogConfig> {
80        let url = self.url("config")?;
81        let response = self
82            .client
83            .get(url)
84            .query(&[
85                ("catalog", catalog),
86                ("protocol-versions", protocol_versions),
87            ])
88            .send()
89            .await?;
90        if !response.status().is_success() {
91            return Err(crate::error::parse_delta_error_response(response).await);
92        }
93        let result = response.bytes().await?;
94        Ok(serde_json::from_slice(&result)?)
95    }
96
97    /// Create a staging table — `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/staging-tables`.
98    ///
99    /// Allocates an immutable table id and a managed storage `location`, and
100    /// advertises the catalog-managed contract (required/suggested protocol +
101    /// properties) plus `READ_WRITE` credentials for writing the initial
102    /// `_delta_log/0.json`. The catalog/schema are taken from the path; the
103    /// request body carries only the table name.
104    pub async fn create_staging_table(
105        &self,
106        catalog: &str,
107        schema: &str,
108        request: &DeltaCreateStagingTableRequest,
109    ) -> Result<DeltaStagingTableResponse> {
110        let url = self.url(&format!(
111            "catalogs/{}/schemas/{}/staging-tables",
112            encode_segment(catalog),
113            encode_segment(schema),
114        ))?;
115        let response = self.client.post(url).json(request).send().await?;
116        if !response.status().is_success() {
117            return Err(crate::error::parse_delta_error_response(response).await);
118        }
119        let result = response.bytes().await?;
120        Ok(serde_json::from_slice(&result)?)
121    }
122
123    /// Finalize a managed table from its staging reservation —
124    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables`.
125    ///
126    /// Called after the client has written `_delta_log/0.json` (carrying the
127    /// required features/properties, including `io.unitycatalog.tableId`) to the
128    /// staging `location`. The server validates the contract and registers the
129    /// table at version 0.
130    pub async fn create_table(
131        &self,
132        catalog: &str,
133        schema: &str,
134        request: &DeltaCreateTableRequest,
135    ) -> Result<DeltaLoadTableResponse> {
136        let url = self.url(&format!(
137            "catalogs/{}/schemas/{}/tables",
138            encode_segment(catalog),
139            encode_segment(schema),
140        ))?;
141        let response = self.client.post(url).json(request).send().await?;
142        if !response.status().is_success() {
143            return Err(crate::error::parse_delta_error_response(response).await);
144        }
145        let result = response.bytes().await?;
146        Ok(serde_json::from_slice(&result)?)
147    }
148
149    /// Load a Delta table's metadata, unbackfilled CCv2 commits, and latest
150    /// ratified version — `GET /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`.
151    ///
152    /// For a catalog-managed (MANAGED) table the response carries the `commits`
153    /// log tail and `latest_table_version` a reader needs to materialize the
154    /// catalog's ratified snapshot.
155    pub async fn load_table(
156        &self,
157        catalog: &str,
158        schema: &str,
159        table: &str,
160    ) -> Result<DeltaLoadTableResponse> {
161        let url = self.table_url(catalog, schema, table, "")?;
162        let response = self.client.get(url).send().await?;
163        if !response.status().is_success() {
164            return Err(crate::error::parse_delta_error_response(response).await);
165        }
166        let result = response.bytes().await?;
167        Ok(serde_json::from_slice(&result)?)
168    }
169
170    /// Update a table — `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`.
171    ///
172    /// This is the catalog-managed commit surface: the `add-commit` action
173    /// proposes a staged commit (the server ratifies iff `version == last + 1`,
174    /// returning `409` on conflict), and `set-latest-backfilled-version` notifies
175    /// the catalog that commits up to a version have been published into
176    /// `_delta_log/`. Metadata/property/schema changes ride the same call.
177    pub async fn update_table(
178        &self,
179        catalog: &str,
180        schema: &str,
181        table: &str,
182        request: &DeltaUpdateTableRequest,
183    ) -> Result<DeltaLoadTableResponse> {
184        let url = self.table_url(catalog, schema, table, "")?;
185        let response = self.client.post(url).json(request).send().await?;
186        if !response.status().is_success() {
187            return Err(crate::error::parse_delta_error_response(response).await);
188        }
189        let result = response.bytes().await?;
190        Ok(serde_json::from_slice(&result)?)
191    }
192
193    /// Delete a table — `DELETE /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`.
194    ///
195    /// The server responds `204 No Content` on success.
196    pub async fn delete_table(&self, catalog: &str, schema: &str, table: &str) -> Result<()> {
197        let url = self.table_url(catalog, schema, table, "")?;
198        let response = self.client.delete(url).send().await?;
199        if !response.status().is_success() {
200            return Err(crate::error::parse_delta_error_response(response).await);
201        }
202        Ok(())
203    }
204
205    /// Check whether a table exists —
206    /// `HEAD /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}`.
207    ///
208    /// Returns `Ok(true)` on `204`, `Ok(false)` on `404`, and an error for any
209    /// other non-success status. A HEAD response carries no body, so a `404` here
210    /// is mapped directly rather than parsed as a Delta error envelope.
211    pub async fn table_exists(&self, catalog: &str, schema: &str, table: &str) -> Result<bool> {
212        let url = self.table_url(catalog, schema, table, "")?;
213        let response = self.client.head(url).send().await?;
214        let status = response.status();
215        if status.is_success() {
216            Ok(true)
217        } else if status == reqwest::StatusCode::NOT_FOUND {
218            Ok(false)
219        } else {
220            Err(crate::error::parse_delta_error_response(response).await)
221        }
222    }
223
224    /// Rename a table within the same catalog and schema —
225    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/rename`.
226    ///
227    /// Cross-schema and cross-catalog moves are not supported. The server responds
228    /// `204 No Content` on success.
229    pub async fn rename_table(
230        &self,
231        catalog: &str,
232        schema: &str,
233        table: &str,
234        request: &DeltaRenameTableRequest,
235    ) -> Result<()> {
236        let url = self.table_url(catalog, schema, table, "/rename")?;
237        let response = self.client.post(url).json(request).send().await?;
238        if !response.status().is_success() {
239            return Err(crate::error::parse_delta_error_response(response).await);
240        }
241        Ok(())
242    }
243
244    /// Vend temporary credentials for a table's data —
245    /// `GET /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/credentials?operation={op}`.
246    ///
247    /// `operation` scopes the credential to `READ` or `READ_WRITE` access.
248    pub async fn get_table_credentials(
249        &self,
250        catalog: &str,
251        schema: &str,
252        table: &str,
253        operation: DeltaCredentialOperation,
254    ) -> Result<DeltaCredentialsResponse> {
255        let url = self.table_url(catalog, schema, table, "/credentials")?;
256        let response = self
257            .client
258            .get(url)
259            .query(&[("operation", operation_param(operation))])
260            .send()
261            .await?;
262        if !response.status().is_success() {
263            return Err(crate::error::parse_delta_error_response(response).await);
264        }
265        let result = response.bytes().await?;
266        Ok(serde_json::from_slice(&result)?)
267    }
268
269    /// Vend `READ_WRITE` credentials for a staging table identified by its UUID —
270    /// `GET /delta/v1/staging-tables/{table_id}/credentials`.
271    ///
272    /// A staging table is globally identified by its UUID (no catalog/schema). This
273    /// is how staging-phase credentials are refreshed while the client writes the
274    /// initial commit.
275    pub async fn get_staging_table_credentials(
276        &self,
277        table_id: &str,
278    ) -> Result<DeltaCredentialsResponse> {
279        let url = self.url(&format!(
280            "staging-tables/{}/credentials",
281            encode_segment(table_id),
282        ))?;
283        let response = self.client.get(url).send().await?;
284        if !response.status().is_success() {
285            return Err(crate::error::parse_delta_error_response(response).await);
286        }
287        let result = response.bytes().await?;
288        Ok(serde_json::from_slice(&result)?)
289    }
290
291    /// Vend temporary credentials for a storage path (for creating a new external
292    /// table) — `GET /delta/v1/temporary-path-credentials?location={location}&operation={op}`.
293    ///
294    /// The path is not yet part of any catalog/namespace, so it is passed as a
295    /// query parameter rather than a path segment.
296    pub async fn get_temporary_path_credentials(
297        &self,
298        location: &str,
299        operation: DeltaCredentialOperation,
300    ) -> Result<DeltaCredentialsResponse> {
301        let url = self.url("temporary-path-credentials")?;
302        let response = self
303            .client
304            .get(url)
305            .query(&[
306                ("location", location),
307                ("operation", operation_param(operation)),
308            ])
309            .send()
310            .await?;
311        if !response.status().is_success() {
312            return Err(crate::error::parse_delta_error_response(response).await);
313        }
314        let result = response.bytes().await?;
315        Ok(serde_json::from_slice(&result)?)
316    }
317
318    /// Report commit metrics (telemetry) for a table —
319    /// `POST /delta/v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/metrics`.
320    ///
321    /// The path `{table}` and the body `table-id` must identify the same table.
322    /// The server responds `204 No Content` on success.
323    pub async fn report_metrics(
324        &self,
325        catalog: &str,
326        schema: &str,
327        table: &str,
328        request: &DeltaReportMetricsRequest,
329    ) -> Result<()> {
330        let url = self.table_url(catalog, schema, table, "/metrics")?;
331        let response = self.client.post(url).json(request).send().await?;
332        if !response.status().is_success() {
333            return Err(crate::error::parse_delta_error_response(response).await);
334        }
335        Ok(())
336    }
337
338    /// Build a `catalogs/{c}/schemas/{s}/tables/{t}{suffix}` URL with each name
339    /// percent-encoded. `suffix` is a literal sub-path (e.g. `"/rename"`) or empty.
340    fn table_url(&self, catalog: &str, schema: &str, table: &str, suffix: &str) -> Result<Url> {
341        self.url(&format!(
342            "catalogs/{}/schemas/{}/tables/{}{suffix}",
343            encode_segment(catalog),
344            encode_segment(schema),
345            encode_segment(table),
346        ))
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use mockito::Server;
354    use unitycatalog_delta_api::models::DeltaErrorType;
355
356    fn test_client(server: &Server) -> DeltaV1Client {
357        let base = Url::parse(&server.url()).unwrap();
358        DeltaV1Client::new(CloudClient::new_unauthenticated(), base)
359    }
360
361    /// A minimal `DeltaLoadTableResponse` JSON body for happy-path assertions.
362    fn load_table_body() -> &'static str {
363        r#"{"metadata":{"etag":"e","table-type":"MANAGED","table-uuid":"u",
364            "location":"s3://b/t","created-time":0,"updated-time":0,
365            "columns":{"type":"struct","fields":[]},"properties":{}}}"#
366    }
367
368    #[tokio::test]
369    async fn load_table_maps_delta_not_found() {
370        let mut server = Server::new_async().await;
371        let m = server
372            .mock("GET", "/delta/v1/catalogs/c/schemas/s/tables/t")
373            .with_status(404)
374            .with_header("content-type", "application/json")
375            .with_body(
376                r#"{"error":{"message":"no table","type":"NoSuchTableException","code":404}}"#,
377            )
378            .create_async()
379            .await;
380
381        let err = test_client(&server)
382            .load_table("c", "s", "t")
383            .await
384            .unwrap_err();
385        m.assert_async().await;
386        assert!(err.is_not_found());
387        assert!(matches!(
388            err,
389            crate::Error::Delta(ref model)
390                if model.error_type == DeltaErrorType::NoSuchTableException
391        ));
392    }
393
394    #[tokio::test]
395    async fn update_table_maps_commit_conflict() {
396        let mut server = Server::new_async().await;
397        let m = server
398            .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t")
399            .with_status(409)
400            .with_body(
401                r#"{"error":{"message":"conflict","type":"CommitVersionConflictException","code":409}}"#,
402            )
403            .create_async()
404            .await;
405
406        let req = DeltaUpdateTableRequest {
407            requirements: vec![],
408            updates: vec![],
409        };
410        let err = test_client(&server)
411            .update_table("c", "s", "t", &req)
412            .await
413            .unwrap_err();
414        m.assert_async().await;
415        assert!(err.is_commit_conflict());
416    }
417
418    #[tokio::test]
419    async fn url_segments_are_percent_encoded() {
420        let mut server = Server::new_async().await;
421        // A schema with a space and a table with a slash must be encoded so they
422        // route to a single resource rather than extra path structure.
423        let m = server
424            .mock(
425                "GET",
426                "/delta/v1/catalogs/c/schemas/my%20schema/tables/weird%2Fname",
427            )
428            .with_status(200)
429            .with_header("content-type", "application/json")
430            .with_body(load_table_body())
431            .create_async()
432            .await;
433
434        let resp = test_client(&server)
435            .load_table("c", "my schema", "weird/name")
436            .await
437            .unwrap();
438        m.assert_async().await;
439        assert_eq!(resp.metadata.location, "s3://b/t");
440    }
441
442    #[tokio::test]
443    async fn table_exists_true_false() {
444        let mut server = Server::new_async().await;
445        let exists = server
446            .mock("HEAD", "/delta/v1/catalogs/c/schemas/s/tables/yes")
447            .with_status(204)
448            .create_async()
449            .await;
450        let missing = server
451            .mock("HEAD", "/delta/v1/catalogs/c/schemas/s/tables/no")
452            .with_status(404)
453            .create_async()
454            .await;
455
456        let client = test_client(&server);
457        assert!(client.table_exists("c", "s", "yes").await.unwrap());
458        assert!(!client.table_exists("c", "s", "no").await.unwrap());
459        exists.assert_async().await;
460        missing.assert_async().await;
461    }
462
463    #[tokio::test]
464    async fn get_config_sends_query_params() {
465        let mut server = Server::new_async().await;
466        let m = server
467            .mock("GET", "/delta/v1/config")
468            .match_query(mockito::Matcher::AllOf(vec![
469                mockito::Matcher::UrlEncoded("catalog".into(), "main".into()),
470                mockito::Matcher::UrlEncoded("protocol-versions".into(), "1.1,2.3".into()),
471            ]))
472            .with_status(200)
473            .with_header("content-type", "application/json")
474            .with_body(r#"{"endpoints":["GET /v1/config"],"protocol-version":"1.0"}"#)
475            .create_async()
476            .await;
477
478        let cfg = test_client(&server)
479            .get_config("main", "1.1,2.3")
480            .await
481            .unwrap();
482        m.assert_async().await;
483        assert_eq!(cfg.protocol_version, "1.0");
484    }
485
486    #[tokio::test]
487    async fn get_table_credentials_sends_operation() {
488        let mut server = Server::new_async().await;
489        let m = server
490            .mock("GET", "/delta/v1/catalogs/c/schemas/s/tables/t/credentials")
491            .match_query(mockito::Matcher::UrlEncoded(
492                "operation".into(),
493                "READ_WRITE".into(),
494            ))
495            .with_status(200)
496            .with_header("content-type", "application/json")
497            .with_body(r#"{"storage-credentials":[]}"#)
498            .create_async()
499            .await;
500
501        let resp = test_client(&server)
502            .get_table_credentials("c", "s", "t", DeltaCredentialOperation::ReadWrite)
503            .await
504            .unwrap();
505        m.assert_async().await;
506        assert!(resp.storage_credentials.is_empty());
507    }
508
509    #[tokio::test]
510    async fn rename_and_delete_and_metrics_no_content() {
511        let mut server = Server::new_async().await;
512        let rename = server
513            .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t/rename")
514            .with_status(204)
515            .create_async()
516            .await;
517        let delete = server
518            .mock("DELETE", "/delta/v1/catalogs/c/schemas/s/tables/t")
519            .with_status(204)
520            .create_async()
521            .await;
522        let metrics = server
523            .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t/metrics")
524            .with_status(204)
525            .create_async()
526            .await;
527
528        let client = test_client(&server);
529        client
530            .rename_table(
531                "c",
532                "s",
533                "t",
534                &DeltaRenameTableRequest {
535                    new_name: "t2".into(),
536                },
537            )
538            .await
539            .unwrap();
540        client.delete_table("c", "s", "t").await.unwrap();
541        client
542            .report_metrics(
543                "c",
544                "s",
545                "t",
546                &DeltaReportMetricsRequest {
547                    table_id: "u".into(),
548                    report: None,
549                },
550            )
551            .await
552            .unwrap();
553        rename.assert_async().await;
554        delete.assert_async().await;
555        metrics.assert_async().await;
556    }
557
558    #[tokio::test]
559    async fn temporary_path_credentials_query() {
560        let mut server = Server::new_async().await;
561        let m = server
562            .mock("GET", "/delta/v1/temporary-path-credentials")
563            .match_query(mockito::Matcher::AllOf(vec![
564                mockito::Matcher::UrlEncoded("location".into(), "s3://bucket/path".into()),
565                mockito::Matcher::UrlEncoded("operation".into(), "READ".into()),
566            ]))
567            .with_status(200)
568            .with_header("content-type", "application/json")
569            .with_body(r#"{"storage-credentials":[]}"#)
570            .create_async()
571            .await;
572
573        test_client(&server)
574            .get_temporary_path_credentials("s3://bucket/path", DeltaCredentialOperation::Read)
575            .await
576            .unwrap();
577        m.assert_async().await;
578    }
579}