1use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
14use unitycatalog_delta_api::models::{
15 DeltaCatalogConfig, DeltaCreateStagingTableRequest, DeltaCreateTableRequest,
16 DeltaCredentialOperation, DeltaCredentialsResponse, DeltaLoadTableResponse,
17 DeltaRenameTableRequest, DeltaReportMetricsRequest, DeltaStagingTableResponse,
18 DeltaUpdateTableRequest,
19};
20use url::Url;
21
22use crate::{Result, Transport};
23
24fn encode_segment(segment: &str) -> String {
30 utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
31}
32
33fn operation_param(operation: DeltaCredentialOperation) -> &'static str {
35 match operation {
36 DeltaCredentialOperation::Read => "READ",
37 DeltaCredentialOperation::ReadWrite => "READ_WRITE",
38 }
39}
40
41#[derive(Clone)]
47pub struct DeltaV1Client {
48 client: Transport,
49 base_url: Url,
50}
51
52impl DeltaV1Client {
53 pub fn new(client: Transport, 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_raw()
89 .await
90 .map_err(crate::Error::from_delta_send)?;
91 let response = crate::error::check_delta_response(response).await?;
92 let result = response.bytes().await?;
93 Ok(serde_json::from_slice(&result)?)
94 }
95
96 pub async fn create_staging_table(
104 &self,
105 catalog: &str,
106 schema: &str,
107 request: &DeltaCreateStagingTableRequest,
108 ) -> Result<DeltaStagingTableResponse> {
109 let url = self.url(&format!(
110 "catalogs/{}/schemas/{}/staging-tables",
111 encode_segment(catalog),
112 encode_segment(schema),
113 ))?;
114 let response = self
115 .client
116 .post(url)
117 .json(request)
118 .send_raw()
119 .await
120 .map_err(crate::Error::from_delta_send)?;
121 let response = crate::error::check_delta_response(response).await?;
122 let result = response.bytes().await?;
123 Ok(serde_json::from_slice(&result)?)
124 }
125
126 pub async fn create_table(
134 &self,
135 catalog: &str,
136 schema: &str,
137 request: &DeltaCreateTableRequest,
138 ) -> Result<DeltaLoadTableResponse> {
139 let url = self.url(&format!(
140 "catalogs/{}/schemas/{}/tables",
141 encode_segment(catalog),
142 encode_segment(schema),
143 ))?;
144 let response = self
145 .client
146 .post(url)
147 .json(request)
148 .send_raw()
149 .await
150 .map_err(crate::Error::from_delta_send)?;
151 let response = crate::error::check_delta_response(response).await?;
152 let result = response.bytes().await?;
153 Ok(serde_json::from_slice(&result)?)
154 }
155
156 pub async fn load_table(
163 &self,
164 catalog: &str,
165 schema: &str,
166 table: &str,
167 ) -> Result<DeltaLoadTableResponse> {
168 let url = self.table_url(catalog, schema, table, "")?;
169 let response = self
170 .client
171 .get(url)
172 .send_raw()
173 .await
174 .map_err(crate::Error::from_delta_send)?;
175 let response = crate::error::check_delta_response(response).await?;
176 let result = response.bytes().await?;
177 Ok(serde_json::from_slice(&result)?)
178 }
179
180 pub async fn update_table(
188 &self,
189 catalog: &str,
190 schema: &str,
191 table: &str,
192 request: &DeltaUpdateTableRequest,
193 ) -> Result<DeltaLoadTableResponse> {
194 let url = self.table_url(catalog, schema, table, "")?;
195 let response = self
196 .client
197 .post(url)
198 .json(request)
199 .send_raw()
200 .await
201 .map_err(crate::Error::from_delta_send)?;
202 let response = crate::error::check_delta_response(response).await?;
203 let result = response.bytes().await?;
204 Ok(serde_json::from_slice(&result)?)
205 }
206
207 pub async fn delete_table(&self, catalog: &str, schema: &str, table: &str) -> Result<()> {
211 let url = self.table_url(catalog, schema, table, "")?;
212 let response = self
213 .client
214 .delete(url)
215 .send_raw()
216 .await
217 .map_err(crate::Error::from_delta_send)?;
218 crate::error::check_delta_response(response).await?;
219 Ok(())
220 }
221
222 pub async fn table_exists(&self, catalog: &str, schema: &str, table: &str) -> Result<bool> {
229 let url = self.table_url(catalog, schema, table, "")?;
230 #[cfg(not(target_arch = "wasm32"))]
235 {
236 match self.client.head(url).send_raw().await {
237 Ok(_) => Ok(true),
239 Err(olai_http::SendRawError::Retry(ref e))
242 if e.status() == Some(reqwest::StatusCode::NOT_FOUND) =>
243 {
244 Ok(false)
245 }
246 Err(e) => Err(crate::Error::from_delta_send(e)),
247 }
248 }
249 #[cfg(target_arch = "wasm32")]
250 {
251 let response = self
252 .client
253 .head(url)
254 .send_raw()
255 .await
256 .map_err(crate::Error::from_delta_send)?;
257 let status = response.status();
258 if status == reqwest::StatusCode::NOT_FOUND {
259 Ok(false)
260 } else if status.is_success() {
261 Ok(true)
262 } else {
263 Err(crate::Error::delta_status(status))
264 }
265 }
266 }
267
268 pub async fn rename_table(
274 &self,
275 catalog: &str,
276 schema: &str,
277 table: &str,
278 request: &DeltaRenameTableRequest,
279 ) -> Result<()> {
280 let url = self.table_url(catalog, schema, table, "/rename")?;
281 let response = self
282 .client
283 .post(url)
284 .json(request)
285 .send_raw()
286 .await
287 .map_err(crate::Error::from_delta_send)?;
288 crate::error::check_delta_response(response).await?;
289 Ok(())
290 }
291
292 pub async fn get_table_credentials(
297 &self,
298 catalog: &str,
299 schema: &str,
300 table: &str,
301 operation: DeltaCredentialOperation,
302 ) -> Result<DeltaCredentialsResponse> {
303 let url = self.table_url(catalog, schema, table, "/credentials")?;
304 let response = self
305 .client
306 .get(url)
307 .query(&[("operation", operation_param(operation))])
308 .send_raw()
309 .await
310 .map_err(crate::Error::from_delta_send)?;
311 let response = crate::error::check_delta_response(response).await?;
312 let result = response.bytes().await?;
313 Ok(serde_json::from_slice(&result)?)
314 }
315
316 pub async fn get_staging_table_credentials(
323 &self,
324 table_id: &str,
325 ) -> Result<DeltaCredentialsResponse> {
326 let url = self.url(&format!(
327 "staging-tables/{}/credentials",
328 encode_segment(table_id),
329 ))?;
330 let response = self
331 .client
332 .get(url)
333 .send_raw()
334 .await
335 .map_err(crate::Error::from_delta_send)?;
336 let response = crate::error::check_delta_response(response).await?;
337 let result = response.bytes().await?;
338 Ok(serde_json::from_slice(&result)?)
339 }
340
341 pub async fn get_temporary_path_credentials(
347 &self,
348 location: &str,
349 operation: DeltaCredentialOperation,
350 ) -> Result<DeltaCredentialsResponse> {
351 let url = self.url("temporary-path-credentials")?;
352 let response = self
353 .client
354 .get(url)
355 .query(&[
356 ("location", location),
357 ("operation", operation_param(operation)),
358 ])
359 .send_raw()
360 .await
361 .map_err(crate::Error::from_delta_send)?;
362 let response = crate::error::check_delta_response(response).await?;
363 let result = response.bytes().await?;
364 Ok(serde_json::from_slice(&result)?)
365 }
366
367 pub async fn report_metrics(
373 &self,
374 catalog: &str,
375 schema: &str,
376 table: &str,
377 request: &DeltaReportMetricsRequest,
378 ) -> Result<()> {
379 let url = self.table_url(catalog, schema, table, "/metrics")?;
380 let response = self
381 .client
382 .post(url)
383 .json(request)
384 .send_raw()
385 .await
386 .map_err(crate::Error::from_delta_send)?;
387 crate::error::check_delta_response(response).await?;
388 Ok(())
389 }
390
391 fn table_url(&self, catalog: &str, schema: &str, table: &str, suffix: &str) -> Result<Url> {
394 self.url(&format!(
395 "catalogs/{}/schemas/{}/tables/{}{suffix}",
396 encode_segment(catalog),
397 encode_segment(schema),
398 encode_segment(table),
399 ))
400 }
401}
402
403#[cfg(all(test, not(target_arch = "wasm32")))]
406mod tests {
407 use super::*;
408 use mockito::Server;
409 use unitycatalog_delta_api::models::DeltaErrorType;
410
411 fn test_client(server: &Server) -> DeltaV1Client {
412 let base = Url::parse(&server.url()).unwrap();
413 DeltaV1Client::new(Transport::new_unauthenticated(), base)
414 }
415
416 fn load_table_body() -> &'static str {
418 r#"{"metadata":{"etag":"e","table-type":"MANAGED","table-uuid":"u",
419 "location":"s3://b/t","created-time":0,"updated-time":0,
420 "columns":{"type":"struct","fields":[]},"properties":{}}}"#
421 }
422
423 #[tokio::test]
424 async fn load_table_maps_delta_not_found() {
425 let mut server = Server::new_async().await;
426 let m = server
427 .mock("GET", "/delta/v1/catalogs/c/schemas/s/tables/t")
428 .with_status(404)
429 .with_header("content-type", "application/json")
430 .with_body(
431 r#"{"error":{"message":"no table","type":"NoSuchTableException","code":404}}"#,
432 )
433 .create_async()
434 .await;
435
436 let err = test_client(&server)
437 .load_table("c", "s", "t")
438 .await
439 .unwrap_err();
440 m.assert_async().await;
441 assert!(err.is_not_found());
442 assert!(matches!(
443 err,
444 crate::Error::Delta(ref model)
445 if model.error_type == DeltaErrorType::NoSuchTableException
446 ));
447 }
448
449 #[tokio::test]
450 async fn update_table_maps_commit_conflict() {
451 let mut server = Server::new_async().await;
452 let m = server
453 .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t")
454 .with_status(409)
455 .with_body(
456 r#"{"error":{"message":"conflict","type":"CommitVersionConflictException","code":409}}"#,
457 )
458 .create_async()
459 .await;
460
461 let req = DeltaUpdateTableRequest {
462 requirements: vec![],
463 updates: vec![],
464 };
465 let err = test_client(&server)
466 .update_table("c", "s", "t", &req)
467 .await
468 .unwrap_err();
469 m.assert_async().await;
470 assert!(err.is_commit_conflict());
471 }
472
473 #[tokio::test]
474 async fn url_segments_are_percent_encoded() {
475 let mut server = Server::new_async().await;
476 let m = server
479 .mock(
480 "GET",
481 "/delta/v1/catalogs/c/schemas/my%20schema/tables/weird%2Fname",
482 )
483 .with_status(200)
484 .with_header("content-type", "application/json")
485 .with_body(load_table_body())
486 .create_async()
487 .await;
488
489 let resp = test_client(&server)
490 .load_table("c", "my schema", "weird/name")
491 .await
492 .unwrap();
493 m.assert_async().await;
494 assert_eq!(resp.metadata.location, "s3://b/t");
495 }
496
497 #[tokio::test]
498 async fn table_exists_true_false() {
499 let mut server = Server::new_async().await;
500 let exists = server
501 .mock("HEAD", "/delta/v1/catalogs/c/schemas/s/tables/yes")
502 .with_status(204)
503 .create_async()
504 .await;
505 let missing = server
506 .mock("HEAD", "/delta/v1/catalogs/c/schemas/s/tables/no")
507 .with_status(404)
508 .create_async()
509 .await;
510
511 let client = test_client(&server);
512 assert!(client.table_exists("c", "s", "yes").await.unwrap());
513 assert!(!client.table_exists("c", "s", "no").await.unwrap());
514 exists.assert_async().await;
515 missing.assert_async().await;
516 }
517
518 #[tokio::test]
519 async fn get_config_sends_query_params() {
520 let mut server = Server::new_async().await;
521 let m = server
522 .mock("GET", "/delta/v1/config")
523 .match_query(mockito::Matcher::AllOf(vec![
524 mockito::Matcher::UrlEncoded("catalog".into(), "main".into()),
525 mockito::Matcher::UrlEncoded("protocol-versions".into(), "1.1,2.3".into()),
526 ]))
527 .with_status(200)
528 .with_header("content-type", "application/json")
529 .with_body(r#"{"endpoints":["GET /v1/config"],"protocol-version":"1.0"}"#)
530 .create_async()
531 .await;
532
533 let cfg = test_client(&server)
534 .get_config("main", "1.1,2.3")
535 .await
536 .unwrap();
537 m.assert_async().await;
538 assert_eq!(cfg.protocol_version, "1.0");
539 }
540
541 #[tokio::test]
542 async fn get_table_credentials_sends_operation() {
543 let mut server = Server::new_async().await;
544 let m = server
545 .mock("GET", "/delta/v1/catalogs/c/schemas/s/tables/t/credentials")
546 .match_query(mockito::Matcher::UrlEncoded(
547 "operation".into(),
548 "READ_WRITE".into(),
549 ))
550 .with_status(200)
551 .with_header("content-type", "application/json")
552 .with_body(r#"{"storage-credentials":[]}"#)
553 .create_async()
554 .await;
555
556 let resp = test_client(&server)
557 .get_table_credentials("c", "s", "t", DeltaCredentialOperation::ReadWrite)
558 .await
559 .unwrap();
560 m.assert_async().await;
561 assert!(resp.storage_credentials.is_empty());
562 }
563
564 #[tokio::test]
565 async fn rename_and_delete_and_metrics_no_content() {
566 let mut server = Server::new_async().await;
567 let rename = server
568 .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t/rename")
569 .with_status(204)
570 .create_async()
571 .await;
572 let delete = server
573 .mock("DELETE", "/delta/v1/catalogs/c/schemas/s/tables/t")
574 .with_status(204)
575 .create_async()
576 .await;
577 let metrics = server
578 .mock("POST", "/delta/v1/catalogs/c/schemas/s/tables/t/metrics")
579 .with_status(204)
580 .create_async()
581 .await;
582
583 let client = test_client(&server);
584 client
585 .rename_table(
586 "c",
587 "s",
588 "t",
589 &DeltaRenameTableRequest {
590 new_name: "t2".into(),
591 },
592 )
593 .await
594 .unwrap();
595 client.delete_table("c", "s", "t").await.unwrap();
596 client
597 .report_metrics(
598 "c",
599 "s",
600 "t",
601 &DeltaReportMetricsRequest {
602 table_id: "u".into(),
603 report: None,
604 },
605 )
606 .await
607 .unwrap();
608 rename.assert_async().await;
609 delete.assert_async().await;
610 metrics.assert_async().await;
611 }
612
613 #[tokio::test]
614 async fn temporary_path_credentials_query() {
615 let mut server = Server::new_async().await;
616 let m = server
617 .mock("GET", "/delta/v1/temporary-path-credentials")
618 .match_query(mockito::Matcher::AllOf(vec![
619 mockito::Matcher::UrlEncoded("location".into(), "s3://bucket/path".into()),
620 mockito::Matcher::UrlEncoded("operation".into(), "READ".into()),
621 ]))
622 .with_status(200)
623 .with_header("content-type", "application/json")
624 .with_body(r#"{"storage-credentials":[]}"#)
625 .create_async()
626 .await;
627
628 test_client(&server)
629 .get_temporary_path_credentials("s3://bucket/path", DeltaCredentialOperation::Read)
630 .await
631 .unwrap();
632 m.assert_async().await;
633 }
634}