Skip to content

Commit e38ee50

Browse files
committed
Add tests for bulk delete request building and response parsing
1 parent 3b44eef commit e38ee50

File tree

2 files changed

+178
-12
lines changed

2 files changed

+178
-12
lines changed

object_store/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ hyper-util = "0.1"
7878
http-body-util = "0.1"
7979
rand = "0.8"
8080
tempfile = "3.1.0"
81+
regex = "1.11.1"
82+
http = "1.1.0"
8183

8284
[[test]]
8385
name = "get_range_file"

object_store/src/azure/client.rs

+176-12
Original file line numberDiff line numberDiff line change
@@ -589,16 +589,12 @@ impl AzureClient {
589589
Ok(())
590590
}
591591

592-
pub async fn bulk_delete_request(&self, paths: Vec<Path>) -> Result<Vec<Result<Path>>> {
593-
if paths.is_empty() {
594-
return Ok(Vec::new());
595-
}
596-
597-
let credential = self.get_credential().await?;
598-
599-
// https://www.ietf.org/rfc/rfc2046
600-
let boundary = format!("batch_{}", uuid::Uuid::new_v4());
601-
592+
fn build_bulk_delete_body(
593+
&self,
594+
boundary: &str,
595+
paths: &[Path],
596+
credential: &Option<Arc<AzureCredential>>,
597+
) -> Vec<u8> {
602598
let mut body_bytes = Vec::with_capacity(paths.len() * 2048);
603599

604600
for (idx, path) in paths.iter().enumerate() {
@@ -612,21 +608,35 @@ impl AzureClient {
612608
// Each subrequest must be authorized individually [1] and we use
613609
// the CredentialExt for this.
614610
// [1]: https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch?tabs=microsoft-entra-id#request-body
615-
.with_azure_authorization(&credential, &self.config.account)
611+
.with_azure_authorization(credential, &self.config.account)
616612
.build()
617613
.unwrap();
618614

619615
// Url for part requests must be relative and without base
620616
let relative_url = self.config.service.make_relative(request.url()).unwrap();
621617

622-
serialize_part_delete_request(&mut body_bytes, &boundary, idx, request, relative_url)
618+
serialize_part_delete_request(&mut body_bytes, boundary, idx, request, relative_url)
623619
}
624620

625621
// Encode end marker
626622
extend(&mut body_bytes, b"--");
627623
extend(&mut body_bytes, boundary.as_bytes());
628624
extend(&mut body_bytes, b"--");
629625
extend(&mut body_bytes, b"\r\n");
626+
body_bytes
627+
}
628+
629+
pub async fn bulk_delete_request(&self, paths: Vec<Path>) -> Result<Vec<Result<Path>>> {
630+
if paths.is_empty() {
631+
return Ok(Vec::new());
632+
}
633+
634+
let credential = self.get_credential().await?;
635+
636+
// https://www.ietf.org/rfc/rfc2046
637+
let boundary = format!("batch_{}", uuid::Uuid::new_v4());
638+
639+
let body_bytes = self.build_bulk_delete_body(&boundary, &paths, &credential);
630640

631641
// Send multipart request
632642
let url = self.config.path_url(&Path::from("/"));
@@ -1085,8 +1095,10 @@ pub(crate) struct UserDelegationKey {
10851095
#[cfg(test)]
10861096
mod tests {
10871097
use bytes::Bytes;
1098+
use regex::bytes::Regex;
10881099

10891100
use super::*;
1101+
use crate::StaticCredentialProvider;
10901102

10911103
#[test]
10921104
fn deserde_azure() {
@@ -1276,4 +1288,156 @@ mod tests {
12761288
let _delegated_key_response_internal: UserDelegationKey =
12771289
quick_xml::de::from_str(S).unwrap();
12781290
}
1291+
1292+
#[tokio::test]
1293+
async fn test_build_bulk_delete_body() {
1294+
let credential_provider = Arc::new(StaticCredentialProvider::new(
1295+
AzureCredential::BearerToken("static-token".to_string()),
1296+
));
1297+
1298+
let config = AzureConfig {
1299+
account: "testaccount".to_string(),
1300+
container: "testcontainer".to_string(),
1301+
credentials: credential_provider,
1302+
service: "http://example.com".try_into().unwrap(),
1303+
retry_config: Default::default(),
1304+
is_emulator: false,
1305+
skip_signature: false,
1306+
disable_tagging: false,
1307+
client_options: Default::default(),
1308+
};
1309+
1310+
let client = AzureClient::new(config).unwrap();
1311+
1312+
let credential = client.get_credential().await.unwrap();
1313+
let paths = &[Path::from("a"), Path::from("b"), Path::from("c")];
1314+
1315+
let boundary = "batch_statictestboundary".to_string();
1316+
1317+
let body_bytes = client.build_bulk_delete_body(&boundary, paths, &credential);
1318+
1319+
// Replace Date header value with a static date
1320+
let re = Regex::new("Date:[^\r]+").unwrap();
1321+
let body_bytes = re
1322+
.replace_all(&body_bytes, b"Date: Tue, 05 Nov 2024 15:01:15 GMT")
1323+
.to_vec();
1324+
1325+
let expected_body = b"--batch_statictestboundary\r
1326+
Content-Type: application/http\r
1327+
Content-Transfer-Encoding: binary\r
1328+
Content-ID: 0\r
1329+
\r
1330+
DELETE /testcontainer/a HTTP/1.1\r
1331+
Content-Length: 0\r
1332+
Date: Tue, 05 Nov 2024 15:01:15 GMT\r
1333+
X-Ms-Version: 2023-11-03\r
1334+
Authorization: Bearer static-token\r
1335+
\r
1336+
\r
1337+
--batch_statictestboundary\r
1338+
Content-Type: application/http\r
1339+
Content-Transfer-Encoding: binary\r
1340+
Content-ID: 1\r
1341+
\r
1342+
DELETE /testcontainer/b HTTP/1.1\r
1343+
Content-Length: 0\r
1344+
Date: Tue, 05 Nov 2024 15:01:15 GMT\r
1345+
X-Ms-Version: 2023-11-03\r
1346+
Authorization: Bearer static-token\r
1347+
\r
1348+
\r
1349+
--batch_statictestboundary\r
1350+
Content-Type: application/http\r
1351+
Content-Transfer-Encoding: binary\r
1352+
Content-ID: 2\r
1353+
\r
1354+
DELETE /testcontainer/c HTTP/1.1\r
1355+
Content-Length: 0\r
1356+
Date: Tue, 05 Nov 2024 15:01:15 GMT\r
1357+
X-Ms-Version: 2023-11-03\r
1358+
Authorization: Bearer static-token\r
1359+
\r
1360+
\r
1361+
--batch_statictestboundary--\r\n"
1362+
.to_vec();
1363+
1364+
assert_eq!(expected_body, body_bytes);
1365+
}
1366+
1367+
#[tokio::test]
1368+
async fn test_parse_blob_batch_delete_response() {
1369+
let response_body = b"--batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed\r
1370+
Content-Type: application/http\r
1371+
Content-ID: 0\r
1372+
\r
1373+
HTTP/1.1 202 Accepted\r
1374+
x-ms-delete-type-permanent: true\r
1375+
x-ms-request-id: 778fdc83-801e-0000-62ff-0334671e284f\r
1376+
x-ms-version: 2018-11-09\r
1377+
\r
1378+
--batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed\r
1379+
Content-Type: application/http\r
1380+
Content-ID: 1\r
1381+
\r
1382+
HTTP/1.1 202 Accepted\r
1383+
x-ms-delete-type-permanent: true\r
1384+
x-ms-request-id: 778fdc83-801e-0000-62ff-0334671e2851\r
1385+
x-ms-version: 2018-11-09\r
1386+
\r
1387+
--batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed\r
1388+
Content-Type: application/http\r
1389+
Content-ID: 2\r
1390+
\r
1391+
HTTP/1.1 404 The specified blob does not exist.\r
1392+
x-ms-error-code: BlobNotFound\r
1393+
x-ms-request-id: 778fdc83-801e-0000-62ff-0334671e2852\r
1394+
x-ms-version: 2018-11-09\r
1395+
Content-Length: 216\r
1396+
Content-Type: application/xml\r
1397+
\r
1398+
<?xml version=\"1.0\" encoding=\"utf-8\"?>
1399+
<Error><Code>BlobNotFound</Code><Message>The specified blob does not exist.
1400+
RequestId:778fdc83-801e-0000-62ff-0334671e2852
1401+
Time:2018-06-14T16:46:54.6040685Z</Message></Error>\r
1402+
--batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed--\r\n";
1403+
1404+
let response: reqwest::Response = http::Response::builder()
1405+
.status(202)
1406+
.header("Transfer-Encoding", "chunked")
1407+
.header(
1408+
"Content-Type",
1409+
"multipart/mixed; boundary=batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed",
1410+
)
1411+
.header("x-ms-request-id", "778fdc83-801e-0000-62ff-033467000000")
1412+
.header("x-ms-version", "2018-11-09")
1413+
.body(Bytes::from(response_body.as_slice()))
1414+
.unwrap()
1415+
.into();
1416+
1417+
let paths = &[Path::from("a"), Path::from("b"), Path::from("c")];
1418+
1419+
let results = parse_blob_batch_delete_response(response, paths)
1420+
.await
1421+
.unwrap();
1422+
1423+
assert!(results[0].is_ok());
1424+
assert_eq!(&paths[0], results[0].as_ref().unwrap());
1425+
1426+
assert!(results[1].is_ok());
1427+
assert_eq!(&paths[1], results[1].as_ref().unwrap());
1428+
1429+
assert!(results[2].is_err());
1430+
let err = results[2].as_ref().unwrap_err();
1431+
let crate::Error::NotFound { source, .. } = err else {
1432+
unreachable!("must be not found")
1433+
};
1434+
let Some(Error::DeleteFailed { path, code, reason }) = source.downcast_ref::<Error>()
1435+
else {
1436+
unreachable!("must be client error")
1437+
};
1438+
1439+
assert_eq!(paths[2].as_ref(), path);
1440+
assert_eq!("404", code);
1441+
assert_eq!("The specified blob does not exist.", reason);
1442+
}
12791443
}

0 commit comments

Comments
 (0)