1use 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
25fn encode_segment(segment: &str) -> String {
31 utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
32}
33
34fn operation_param(operation: DeltaCredentialOperation) -> &'static str {
36 match operation {
37 DeltaCredentialOperation::Read => "READ",
38 DeltaCredentialOperation::ReadWrite => "READ_WRITE",
39 }
40}
41
42#[derive(Clone)]
47pub struct DeltaV1Client {
48 client: CloudClient,
49 base_url: Url,
50}
51
52impl DeltaV1Client {
53 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 fn url(&self, rest: &str) -> Result<Url> {
64 Ok(self.base_url.join(&format!("delta/v1/{rest}"))?)
65 }
66
67 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}