@@ -205,6 +205,7 @@ pub(crate) struct S3Config {
205205 pub sign_payload : bool ,
206206 pub skip_signature : bool ,
207207 pub disable_tagging : bool ,
208+ pub disable_bulk_delete : bool ,
208209 pub checksum : Option < Checksum > ,
209210 pub copy_if_not_exists : Option < S3CopyIfNotExists > ,
210211 pub conditional_put : S3ConditionalPut ,
@@ -615,6 +616,16 @@ impl S3Client {
615616 Ok ( results)
616617 }
617618
619+ /// Make a single-object S3 Delete request <https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html>
620+ ///
621+ /// Unlike [`bulk_delete_request`](Self::bulk_delete_request), this issues a
622+ /// plain `DELETE /key` request, which is part of the core S3 API and is
623+ /// supported by every S3-compatible provider.
624+ pub ( crate ) async fn delete_request ( & self , path : & Path ) -> Result < ( ) > {
625+ self . request ( Method :: DELETE , path) . send ( ) . await ?;
626+ Ok ( ( ) )
627+ }
628+
618629 /// Make an S3 Copy request <https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html>
619630 pub ( crate ) fn copy_request < ' a > ( & ' a self , from : & Path , to : & ' a Path ) -> Request < ' a > {
620631 let source = format ! ( "{}/{}" , self . config. bucket, encode_path( from) ) ;
@@ -1008,10 +1019,13 @@ fn encode_path(path: &Path) -> PercentEncode<'_> {
10081019mod tests {
10091020 use super :: * ;
10101021 use crate :: GetOptions ;
1022+ use crate :: ObjectStore ;
1023+ use crate :: aws:: { AmazonS3 , AmazonS3Builder } ;
10111024 use crate :: client:: HttpClient ;
10121025 use crate :: client:: get:: GetClient ;
10131026 use crate :: client:: mock_server:: MockServer ;
10141027 use crate :: client:: retry:: RetryContext ;
1028+ use futures_util:: { StreamExt , TryStreamExt } ;
10151029 use http:: Response ;
10161030 use http:: header:: { AUTHORIZATION , CONTENT_LENGTH } ;
10171031 use hyper:: Request ;
@@ -1049,6 +1063,7 @@ mod tests {
10491063 retry_config : Default :: default ( ) ,
10501064 sign_payload : false ,
10511065 disable_tagging : false ,
1066+ disable_bulk_delete : false ,
10521067 checksum : None ,
10531068 copy_if_not_exists : None ,
10541069 conditional_put : Default :: default ( ) ,
@@ -1104,6 +1119,7 @@ mod tests {
11041119 retry_config : Default :: default ( ) ,
11051120 sign_payload : false ,
11061121 disable_tagging : false ,
1122+ disable_bulk_delete : false ,
11071123 checksum : None ,
11081124 copy_if_not_exists : None ,
11091125 conditional_put : Default :: default ( ) ,
@@ -1155,6 +1171,163 @@ mod tests {
11551171 mock. shutdown ( ) . await ;
11561172 }
11571173
1174+ /// `(method, path, query)` captured for assertion outside the mock closure.
1175+ ///
1176+ /// `MockServer` swallows panics raised inside its response handler (the
1177+ /// connection just resets and the S3 retry logic can still surface an `Ok`
1178+ /// result), so assertions placed inside the closure are silently ignored.
1179+ /// We capture into shared state and assert in the test body instead.
1180+ type CapturedRequest = ( Method , String , Option < String > ) ;
1181+
1182+ fn capture ( captured : & Arc < std:: sync:: Mutex < Vec < CapturedRequest > > > , req : & Request < Incoming > ) {
1183+ captured. lock ( ) . unwrap ( ) . push ( (
1184+ req. method ( ) . clone ( ) ,
1185+ req. uri ( ) . path ( ) . to_string ( ) ,
1186+ req. uri ( ) . query ( ) . map ( |s| s. to_string ( ) ) ,
1187+ ) ) ;
1188+ }
1189+
1190+ /// Build an `AmazonS3` via the public builder so that `bucket_endpoint`
1191+ /// is computed by the library from the addressing-style option — i.e.
1192+ /// the option under test actually drives the URL the client emits.
1193+ fn make_store ( mock : & MockServer , virtual_hosted : bool , disable_bulk_delete : bool ) -> AmazonS3 {
1194+ AmazonS3Builder :: new ( )
1195+ . with_endpoint ( mock. url ( ) )
1196+ . with_bucket_name ( "test-bucket" )
1197+ . with_region ( "us-east-1" )
1198+ . with_allow_http ( true )
1199+ . with_skip_signature ( true )
1200+ . with_virtual_hosted_style_request ( virtual_hosted)
1201+ . with_disable_bulk_delete ( disable_bulk_delete)
1202+ . build ( )
1203+ . unwrap ( )
1204+ }
1205+
1206+ #[ tokio:: test]
1207+ async fn test_delete_default ( ) {
1208+ // Default: path-style + bulk delete enabled.
1209+ // `delete_stream` must issue a single `POST /{bucket}?delete`.
1210+ let mock = MockServer :: new ( ) . await ;
1211+ let captured: Arc < std:: sync:: Mutex < Vec < CapturedRequest > > > = Default :: default ( ) ;
1212+ let c = Arc :: clone ( & captured) ;
1213+ mock. push_fn ( move |req| {
1214+ capture ( & c, & req) ;
1215+ Response :: builder ( )
1216+ . status ( 200 )
1217+ . body ( "<DeleteResult><Deleted><Key>foo</Key></Deleted></DeleteResult>" . to_string ( ) )
1218+ . unwrap ( )
1219+ } ) ;
1220+
1221+ let store = make_store ( & mock, false , false ) ;
1222+ let locations = futures_util:: stream:: iter ( vec ! [ Ok ( Path :: from( "foo" ) ) ] ) . boxed ( ) ;
1223+ let deleted: Vec < _ > = store. delete_stream ( locations) . try_collect ( ) . await . unwrap ( ) ;
1224+ assert_eq ! ( deleted. len( ) , 1 ) ;
1225+
1226+ let captured = captured. lock ( ) . unwrap ( ) . clone ( ) ;
1227+ assert_eq ! ( captured. len( ) , 1 , "expected one bulk delete request" ) ;
1228+ assert_eq ! ( captured[ 0 ] . 0 , Method :: POST ) ;
1229+ assert_eq ! ( captured[ 0 ] . 1 , "/test-bucket" ) ;
1230+ assert_eq ! ( captured[ 0 ] . 2 . as_deref( ) , Some ( "delete" ) ) ;
1231+
1232+ mock. shutdown ( ) . await ;
1233+ }
1234+
1235+ #[ tokio:: test]
1236+ async fn test_delete_default_with_disable_bulk ( ) {
1237+ // Path-style + bulk delete disabled.
1238+ // `delete_stream` must fan out into `DELETE /{bucket}/{key}` (one per
1239+ // object, no `?delete` query).
1240+ let mock = MockServer :: new ( ) . await ;
1241+ let captured: Arc < std:: sync:: Mutex < Vec < CapturedRequest > > > = Default :: default ( ) ;
1242+ for _ in 0 ..2 {
1243+ let c = Arc :: clone ( & captured) ;
1244+ mock. push_fn ( move |req| {
1245+ capture ( & c, & req) ;
1246+ Response :: builder ( ) . status ( 204 ) . body ( String :: new ( ) ) . unwrap ( )
1247+ } ) ;
1248+ }
1249+
1250+ let store = make_store ( & mock, false , true ) ;
1251+ let locations =
1252+ futures_util:: stream:: iter ( vec ! [ Ok ( Path :: from( "foo" ) ) , Ok ( Path :: from( "bar" ) ) ] ) . boxed ( ) ;
1253+ let deleted: Vec < _ > = store. delete_stream ( locations) . try_collect ( ) . await . unwrap ( ) ;
1254+ assert_eq ! ( deleted. len( ) , 2 ) ;
1255+
1256+ let mut captured = captured. lock ( ) . unwrap ( ) . clone ( ) ;
1257+ captured. sort_by ( |a, b| a. 1 . cmp ( & b. 1 ) ) ;
1258+ assert_eq ! ( captured. len( ) , 2 , "expected one DELETE per object" ) ;
1259+ assert_eq ! (
1260+ captured[ 0 ] ,
1261+ ( Method :: DELETE , "/test-bucket/bar" . to_string( ) , None )
1262+ ) ;
1263+ assert_eq ! (
1264+ captured[ 1 ] ,
1265+ ( Method :: DELETE , "/test-bucket/foo" . to_string( ) , None )
1266+ ) ;
1267+
1268+ mock. shutdown ( ) . await ;
1269+ }
1270+
1271+ #[ tokio:: test]
1272+ async fn test_delete_virtual_hosted ( ) {
1273+ // Virtual-hosted style + bulk delete enabled.
1274+ // `delete_stream` must issue a single `POST /?delete` (bucket is in
1275+ // the host, not the path).
1276+ let mock = MockServer :: new ( ) . await ;
1277+ let captured: Arc < std:: sync:: Mutex < Vec < CapturedRequest > > > = Default :: default ( ) ;
1278+ let c = Arc :: clone ( & captured) ;
1279+ mock. push_fn ( move |req| {
1280+ capture ( & c, & req) ;
1281+ Response :: builder ( )
1282+ . status ( 200 )
1283+ . body ( "<DeleteResult><Deleted><Key>foo</Key></Deleted></DeleteResult>" . to_string ( ) )
1284+ . unwrap ( )
1285+ } ) ;
1286+
1287+ let store = make_store ( & mock, true , false ) ;
1288+ let locations = futures_util:: stream:: iter ( vec ! [ Ok ( Path :: from( "foo" ) ) ] ) . boxed ( ) ;
1289+ let deleted: Vec < _ > = store. delete_stream ( locations) . try_collect ( ) . await . unwrap ( ) ;
1290+ assert_eq ! ( deleted. len( ) , 1 ) ;
1291+
1292+ let captured = captured. lock ( ) . unwrap ( ) . clone ( ) ;
1293+ assert_eq ! ( captured. len( ) , 1 , "expected one bulk delete request" ) ;
1294+ assert_eq ! ( captured[ 0 ] . 0 , Method :: POST ) ;
1295+ assert_eq ! ( captured[ 0 ] . 1 , "/" ) ;
1296+ assert_eq ! ( captured[ 0 ] . 2 . as_deref( ) , Some ( "delete" ) ) ;
1297+
1298+ mock. shutdown ( ) . await ;
1299+ }
1300+
1301+ #[ tokio:: test]
1302+ async fn test_delete_virtual_hosted_with_disable_bulk ( ) {
1303+ // Virtual-hosted style + bulk delete disabled.
1304+ // `delete_stream` must fan out into `DELETE /{key}` (no bucket in
1305+ // path, no `?delete` query).
1306+ let mock = MockServer :: new ( ) . await ;
1307+ let captured: Arc < std:: sync:: Mutex < Vec < CapturedRequest > > > = Default :: default ( ) ;
1308+ for _ in 0 ..2 {
1309+ let c = Arc :: clone ( & captured) ;
1310+ mock. push_fn ( move |req| {
1311+ capture ( & c, & req) ;
1312+ Response :: builder ( ) . status ( 204 ) . body ( String :: new ( ) ) . unwrap ( )
1313+ } ) ;
1314+ }
1315+
1316+ let store = make_store ( & mock, true , true ) ;
1317+ let locations =
1318+ futures_util:: stream:: iter ( vec ! [ Ok ( Path :: from( "foo" ) ) , Ok ( Path :: from( "bar" ) ) ] ) . boxed ( ) ;
1319+ let deleted: Vec < _ > = store. delete_stream ( locations) . try_collect ( ) . await . unwrap ( ) ;
1320+ assert_eq ! ( deleted. len( ) , 2 ) ;
1321+
1322+ let mut captured = captured. lock ( ) . unwrap ( ) . clone ( ) ;
1323+ captured. sort_by ( |a, b| a. 1 . cmp ( & b. 1 ) ) ;
1324+ assert_eq ! ( captured. len( ) , 2 , "expected one DELETE per object" ) ;
1325+ assert_eq ! ( captured[ 0 ] , ( Method :: DELETE , "/bar" . to_string( ) , None ) ) ;
1326+ assert_eq ! ( captured[ 1 ] , ( Method :: DELETE , "/foo" . to_string( ) , None ) ) ;
1327+
1328+ mock. shutdown ( ) . await ;
1329+ }
1330+
11581331 #[ tokio:: test]
11591332 async fn test_default_headers_signed_get_request ( ) {
11601333 let mock = MockServer :: new ( ) . await ;
0 commit comments