Skip to main content

aliyun_oss/operations/
object_copy.rs

1//! Copy object operations.
2
3use std::sync::Arc;
4
5use serde::Deserialize;
6
7use crate::client::{BucketOperations, OSSClientInner};
8use crate::error::{ErrorContext, OssError, OssErrorKind, Result};
9use crate::http::client::HttpRequest;
10use crate::types::acl::ObjectAcl;
11use crate::types::bucket::BucketName;
12use crate::types::object::ObjectKey;
13use crate::types::storage::StorageClass;
14use crate::util::uri::oss_endpoint_url;
15
16pub struct CopyObjectBuilder {
17    client: Arc<OSSClientInner>,
18    dest_bucket: BucketName,
19    dest_key: ObjectKey,
20    source_bucket: BucketName,
21    source_key: ObjectKey,
22    source_version_id: Option<String>,
23    if_match: Option<String>,
24    if_none_match: Option<String>,
25    if_modified_since: Option<String>,
26    if_unmodified_since: Option<String>,
27    metadata_directive: Option<String>,
28    acl: Option<ObjectAcl>,
29    storage_class: Option<StorageClass>,
30    server_side_encryption: Option<String>,
31    sse_key_id: Option<String>,
32}
33
34impl CopyObjectBuilder {
35    pub(crate) fn new(
36        client: Arc<OSSClientInner>,
37        dest_bucket: BucketName,
38        dest_key: ObjectKey,
39        source_bucket: BucketName,
40        source_key: ObjectKey,
41    ) -> Self {
42        Self {
43            client,
44            dest_bucket,
45            dest_key,
46            source_bucket,
47            source_key,
48            source_version_id: None,
49            if_match: None,
50            if_none_match: None,
51            if_modified_since: None,
52            if_unmodified_since: None,
53            metadata_directive: None,
54            acl: None,
55            storage_class: None,
56            server_side_encryption: None,
57            sse_key_id: None,
58        }
59    }
60
61    pub fn source_version_id(mut self, id: impl Into<String>) -> Self {
62        self.source_version_id = Some(id.into());
63        self
64    }
65
66    pub fn if_match(mut self, etag: impl Into<String>) -> Self {
67        self.if_match = Some(etag.into());
68        self
69    }
70
71    pub fn if_none_match(mut self, etag: impl Into<String>) -> Self {
72        self.if_none_match = Some(etag.into());
73        self
74    }
75
76    pub fn if_modified_since(mut self, time: impl Into<String>) -> Self {
77        self.if_modified_since = Some(time.into());
78        self
79    }
80
81    pub fn if_unmodified_since(mut self, time: impl Into<String>) -> Self {
82        self.if_unmodified_since = Some(time.into());
83        self
84    }
85
86    pub fn metadata_directive_copy(mut self) -> Self {
87        self.metadata_directive = Some("COPY".into());
88        self
89    }
90
91    pub fn metadata_directive_replace(mut self) -> Self {
92        self.metadata_directive = Some("REPLACE".into());
93        self
94    }
95
96    pub fn acl(mut self, acl: ObjectAcl) -> Self {
97        self.acl = Some(acl);
98        self
99    }
100
101    pub fn storage_class(mut self, sc: StorageClass) -> Self {
102        self.storage_class = Some(sc);
103        self
104    }
105
106    pub fn server_side_encryption(mut self, sse: impl Into<String>) -> Self {
107        self.server_side_encryption = Some(sse.into());
108        self
109    }
110
111    pub fn sse_key_id(mut self, key_id: impl Into<String>) -> Self {
112        self.sse_key_id = Some(key_id.into());
113        self
114    }
115
116    pub async fn send(self) -> Result<CopyObjectOutput> {
117        let endpoint = self.client.endpoint.clone();
118        let uri = oss_endpoint_url(
119            &endpoint,
120            Some(self.dest_bucket.as_str()),
121            Some(self.dest_key.as_str()),
122        );
123
124        let source = format!(
125            "/{}/{}",
126            self.source_bucket.as_str(),
127            crate::util::uri::uri_encode(self.source_key.as_str())
128        );
129
130        let mut req = HttpRequest::builder().method(http::Method::PUT).uri(&uri);
131
132        req = req.header(
133            http::HeaderName::from_static("x-oss-copy-source"),
134            http::HeaderValue::from_str(&source).map_err(|e| OssError {
135                kind: OssErrorKind::ValidationError,
136                context: Box::new(ErrorContext {
137                    operation: Some("set x-oss-copy-source header".into()),
138                    bucket: Some(self.dest_bucket.to_string()),
139                    object_key: Some(self.dest_key.to_string()),
140                    ..Default::default()
141                }),
142                source: Some(Box::new(e)),
143            })?,
144        );
145
146        if let Some(ref im) = self.if_match {
147            req = req.header(
148                http::HeaderName::from_static("x-oss-copy-source-if-match"),
149                http::HeaderValue::from_str(im).map_err(|e| OssError {
150                    kind: OssErrorKind::ValidationError,
151                    context: Box::new(ErrorContext {
152                        operation: Some("set x-oss-copy-source-if-match header".into()),
153                        bucket: Some(self.dest_bucket.to_string()),
154                        object_key: Some(self.dest_key.to_string()),
155                        ..Default::default()
156                    }),
157                    source: Some(Box::new(e)),
158                })?,
159            );
160        }
161
162        if let Some(ref inm) = self.if_none_match {
163            req = req.header(
164                http::HeaderName::from_static("x-oss-copy-source-if-none-match"),
165                http::HeaderValue::from_str(inm).map_err(|e| OssError {
166                    kind: OssErrorKind::ValidationError,
167                    context: Box::new(ErrorContext {
168                        operation: Some("set x-oss-copy-source-if-none-match header".into()),
169                        bucket: Some(self.dest_bucket.to_string()),
170                        object_key: Some(self.dest_key.to_string()),
171                        ..Default::default()
172                    }),
173                    source: Some(Box::new(e)),
174                })?,
175            );
176        }
177
178        if let Some(ref ims) = self.if_modified_since {
179            req = req.header(
180                http::HeaderName::from_static("x-oss-copy-source-if-modified-since"),
181                http::HeaderValue::from_str(ims).map_err(|e| OssError {
182                    kind: OssErrorKind::ValidationError,
183                    context: Box::new(ErrorContext {
184                        operation: Some("set x-oss-copy-source-if-modified-since header".into()),
185                        bucket: Some(self.dest_bucket.to_string()),
186                        object_key: Some(self.dest_key.to_string()),
187                        ..Default::default()
188                    }),
189                    source: Some(Box::new(e)),
190                })?,
191            );
192        }
193
194        if let Some(ref ius) = self.if_unmodified_since {
195            req = req.header(
196                http::HeaderName::from_static("x-oss-copy-source-if-unmodified-since"),
197                http::HeaderValue::from_str(ius).map_err(|e| OssError {
198                    kind: OssErrorKind::ValidationError,
199                    context: Box::new(ErrorContext {
200                        operation: Some("set x-oss-copy-source-if-unmodified-since header".into()),
201                        bucket: Some(self.dest_bucket.to_string()),
202                        object_key: Some(self.dest_key.to_string()),
203                        ..Default::default()
204                    }),
205                    source: Some(Box::new(e)),
206                })?,
207            );
208        }
209
210        if let Some(ref md) = self.metadata_directive {
211            req = req.header(
212                http::HeaderName::from_static("x-oss-metadata-directive"),
213                http::HeaderValue::from_str(md).map_err(|e| OssError {
214                    kind: OssErrorKind::ValidationError,
215                    context: Box::new(ErrorContext {
216                        operation: Some("set x-oss-metadata-directive header".into()),
217                        bucket: Some(self.dest_bucket.to_string()),
218                        object_key: Some(self.dest_key.to_string()),
219                        ..Default::default()
220                    }),
221                    source: Some(Box::new(e)),
222                })?,
223            );
224        }
225
226        if let Some(acl) = self.acl {
227            req = req.header(
228                http::HeaderName::from_static("x-oss-object-acl"),
229                http::HeaderValue::from_str(acl.as_str()).map_err(|e| OssError {
230                    kind: OssErrorKind::ValidationError,
231                    context: Box::new(ErrorContext {
232                        operation: Some("set x-oss-object-acl header".into()),
233                        bucket: Some(self.dest_bucket.to_string()),
234                        object_key: Some(self.dest_key.to_string()),
235                        ..Default::default()
236                    }),
237                    source: Some(Box::new(e)),
238                })?,
239            );
240        }
241
242        if let Some(sc) = self.storage_class {
243            req = req.header(
244                http::HeaderName::from_static("x-oss-storage-class"),
245                http::HeaderValue::from_str(sc.as_str()).map_err(|e| OssError {
246                    kind: OssErrorKind::ValidationError,
247                    context: Box::new(ErrorContext {
248                        operation: Some("set x-oss-storage-class header".into()),
249                        bucket: Some(self.dest_bucket.to_string()),
250                        object_key: Some(self.dest_key.to_string()),
251                        ..Default::default()
252                    }),
253                    source: Some(Box::new(e)),
254                })?,
255            );
256        }
257
258        if let Some(ref sse) = self.server_side_encryption {
259            req = req.header(
260                http::HeaderName::from_static("x-oss-server-side-encryption"),
261                http::HeaderValue::from_str(sse).map_err(|e| OssError {
262                    kind: OssErrorKind::ValidationError,
263                    context: Box::new(ErrorContext {
264                        operation: Some("set x-oss-server-side-encryption header".into()),
265                        bucket: Some(self.dest_bucket.to_string()),
266                        object_key: Some(self.dest_key.to_string()),
267                        ..Default::default()
268                    }),
269                    source: Some(Box::new(e)),
270                })?,
271            );
272        }
273
274        if let Some(ref key_id) = self.sse_key_id {
275            req = req.header(
276                http::HeaderName::from_static("x-oss-server-side-encryption-key-id"),
277                http::HeaderValue::from_str(key_id).map_err(|e| OssError {
278                    kind: OssErrorKind::ValidationError,
279                    context: Box::new(ErrorContext {
280                        operation: Some("set x-oss-server-side-encryption-key-id header".into()),
281                        bucket: Some(self.dest_bucket.to_string()),
282                        object_key: Some(self.dest_key.to_string()),
283                        ..Default::default()
284                    }),
285                    source: Some(Box::new(e)),
286                })?,
287            );
288        }
289
290        let request = req.build();
291
292        let response = self
293            .client
294            .send_signed(request, Some(&self.dest_bucket), Vec::new())
295            .await
296            .map_err(|e| OssError {
297                kind: OssErrorKind::TransportError,
298                context: Box::new(ErrorContext {
299                    operation: Some("CopyObject".into()),
300                    bucket: Some(self.dest_bucket.to_string()),
301                    object_key: Some(self.dest_key.to_string()),
302                    endpoint: Some(endpoint),
303                    ..Default::default()
304                }),
305                source: Some(Box::new(e)),
306            })?;
307
308        if response.is_success() {
309            let request_id = response
310                .headers
311                .get("x-oss-request-id")
312                .and_then(|v| v.to_str().ok())
313                .unwrap_or("")
314                .to_string();
315
316            let result: CopyObjectResult =
317                crate::util::xml::from_xml(response.body_as_str().unwrap_or(""))?;
318
319            Ok(CopyObjectOutput {
320                request_id,
321                etag: result.etag.trim_matches('"').to_string(),
322                last_modified: result.last_modified,
323            })
324        } else {
325            Err(OssError {
326                kind: OssErrorKind::ServiceError(Box::new(crate::error::OssServiceError {
327                    status_code: response.status().as_u16(),
328                    code: String::new(),
329                    message: String::new(),
330                    request_id: String::new(),
331                    host_id: String::new(),
332                    resource: Some(self.dest_key.to_string()),
333                    string_to_sign: None,
334                })),
335                context: Box::new(ErrorContext {
336                    operation: Some("CopyObject".into()),
337                    bucket: Some(self.dest_bucket.to_string()),
338                    object_key: Some(self.dest_key.to_string()),
339                    ..Default::default()
340                }),
341                source: None,
342            })
343        }
344    }
345}
346
347#[derive(Debug, Clone, Deserialize, PartialEq)]
348#[serde(rename = "CopyObjectResult")]
349struct CopyObjectResult {
350    #[serde(rename = "ETag")]
351    pub(crate) etag: String,
352    #[serde(rename = "LastModified")]
353    pub(crate) last_modified: String,
354}
355
356#[derive(Debug, Clone)]
357pub struct CopyObjectOutput {
358    pub request_id: String,
359    pub etag: String,
360    pub last_modified: String,
361}
362
363impl BucketOperations {
364    pub fn copy_object(
365        &self,
366        dest_key: impl Into<String>,
367        source_bucket: &BucketName,
368        source_key: &ObjectKey,
369    ) -> Result<CopyObjectBuilder> {
370        let dest_key = ObjectKey::new(dest_key.into())?;
371        Ok(CopyObjectBuilder::new(
372            self.client_inner().clone(),
373            self.bucket_name().clone(),
374            dest_key,
375            source_bucket.clone(),
376            source_key.clone(),
377        ))
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use std::str::FromStr;
384    use std::sync::Mutex;
385
386    use http::HeaderMap;
387
388    use crate::client::OSSClientInner;
389    use crate::config::credentials::Credentials;
390    use crate::http::client::{HttpClient, HttpRequest, HttpResponse};
391    use crate::types::region::Region;
392
393    use super::*;
394
395    struct RecordingHttpClient {
396        requests: Arc<Mutex<Vec<HttpRequest>>>,
397        response_body: bytes::Bytes,
398    }
399
400    #[async_trait::async_trait]
401    impl HttpClient for RecordingHttpClient {
402        async fn send(&self, request: HttpRequest) -> crate::error::Result<HttpResponse> {
403            self.requests.lock().unwrap().push(request);
404            let mut headers = HeaderMap::new();
405            headers.insert(
406                "x-oss-request-id",
407                http::HeaderValue::from_static("rid-copy"),
408            );
409            Ok(HttpResponse {
410                status: http::StatusCode::OK,
411                headers,
412                body: self.response_body.clone(),
413            })
414        }
415    }
416
417    fn create_test_inner(
418        response_body: bytes::Bytes,
419    ) -> (Arc<OSSClientInner>, Arc<Mutex<Vec<HttpRequest>>>) {
420        let requests = Arc::new(Mutex::new(Vec::new()));
421        let http = Arc::new(RecordingHttpClient {
422            requests: requests.clone(),
423            response_body,
424        });
425        let credentials = Arc::new(crate::config::credentials::StaticCredentialsProvider::new(
426            Credentials::builder()
427                .access_key_id("test-ak")
428                .access_key_secret("test-sk")
429                .build()
430                .unwrap(),
431        ));
432        let inner = Arc::new(OSSClientInner {
433            http,
434            credentials,
435            signer: Arc::from(crate::signer::create_signer(crate::signer::SignVersion::V4)),
436            region: Region::CnHangzhou,
437            endpoint: "oss-cn-hangzhou.aliyuncs.com".into(),
438        });
439        (inner, requests)
440    }
441
442    #[tokio::test]
443    async fn copy_object_sends_correct_request() {
444        let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?>
445<CopyObjectResult>
446  <ETag>"abc789"</ETag>
447  <LastModified>2024-06-01T00:00:00.000Z</LastModified>
448</CopyObjectResult>"#;
449
450        let (inner, requests) = create_test_inner(bytes::Bytes::from(xml_body));
451        let source_bucket = BucketName::new("source-bucket").unwrap();
452        let source_key = ObjectKey::new("source/file.txt").unwrap();
453        let builder = CopyObjectBuilder::new(
454            inner,
455            BucketName::new("dest-bucket").unwrap(),
456            ObjectKey::new("dest/file.txt").unwrap(),
457            source_bucket,
458            source_key,
459        );
460
461        let output = builder.send().await.unwrap();
462
463        assert_eq!(output.etag, "abc789");
464        assert!(!output.request_id.is_empty());
465
466        let captured = requests.lock().unwrap();
467        assert_eq!(captured[0].method, http::Method::PUT);
468        assert!(captured[0].uri.contains("dest-bucket"));
469        assert!(captured[0].uri.contains("dest/file.txt"));
470
471        let copy_source = captured[0]
472            .headers
473            .get("x-oss-copy-source")
474            .unwrap()
475            .to_str()
476            .unwrap();
477        assert_eq!(copy_source, "/source-bucket/source/file.txt");
478    }
479
480    #[tokio::test]
481    async fn copy_object_with_metadata_directive() {
482        let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?>
483<CopyObjectResult>
484  <ETag>"etag"</ETag>
485  <LastModified>2024-01-01T00:00:00.000Z</LastModified>
486</CopyObjectResult>"#;
487
488        let (inner, requests) = create_test_inner(bytes::Bytes::from(xml_body));
489        let builder = CopyObjectBuilder::new(
490            inner,
491            BucketName::new("dest-bucket").unwrap(),
492            ObjectKey::new("dest.txt").unwrap(),
493            BucketName::new("src-bucket").unwrap(),
494            ObjectKey::new("src.txt").unwrap(),
495        );
496
497        builder.metadata_directive_replace().send().await.unwrap();
498
499        let captured = requests.lock().unwrap();
500        assert_eq!(
501            captured[0]
502                .headers
503                .get("x-oss-metadata-directive")
504                .unwrap()
505                .to_str()
506                .unwrap(),
507            "REPLACE"
508        );
509    }
510
511    #[tokio::test]
512    async fn copy_object_source_encoding() {
513        let xml_body = r#"<?xml version="1.0" encoding="UTF-8"?>
514<CopyObjectResult>
515  <ETag>"etag"</ETag>
516  <LastModified>2024-01-01T00:00:00.000Z</LastModified>
517</CopyObjectResult>"#;
518
519        let (inner, requests) = create_test_inner(bytes::Bytes::from(xml_body));
520        let builder = CopyObjectBuilder::new(
521            inner,
522            BucketName::new("dest-bucket").unwrap(),
523            ObjectKey::new("dest.txt").unwrap(),
524            BucketName::new("src-bucket").unwrap(),
525            ObjectKey::new("文件 名.txt").unwrap(),
526        );
527
528        builder.send().await.unwrap();
529
530        let captured = requests.lock().unwrap();
531        let copy_source = captured[0]
532            .headers
533            .get("x-oss-copy-source")
534            .unwrap()
535            .to_str()
536            .unwrap();
537        assert!(copy_source.contains("%E6%96%87%E4%BB%B6%20%E5%90%8D.txt"));
538    }
539
540    #[tokio::test]
541    #[ignore = "requires valid OSS credentials"]
542    async fn e2e_copy_object() {
543        let ak = std::env::var("OSS_ACCESS_KEY_ID").expect("OSS_ACCESS_KEY_ID not set");
544        let sk = std::env::var("OSS_ACCESS_KEY_SECRET").expect("OSS_ACCESS_KEY_SECRET not set");
545        let region_str = std::env::var("OSS_REGION").unwrap_or_else(|_| "cn-hangzhou".into());
546        let bucket_str = std::env::var("OSS_BUCKET").expect("OSS_BUCKET not set");
547
548        let region = Region::from_str(&region_str).unwrap_or_else(|_| Region::Custom {
549            endpoint: format!("oss-{}.aliyuncs.com", region_str),
550            region_id: region_str.clone(),
551        });
552
553        let client = crate::client::OSSClient::builder()
554            .region(region)
555            .credentials(ak, sk)
556            .build()
557            .unwrap();
558
559        let src_key = format!("test-copy-src-{}.txt", chrono::Utc::now().timestamp());
560        let dest_key = format!("test-copy-dest-{}.txt", chrono::Utc::now().timestamp());
561        let content = "CopyObject E2E test content";
562
563        client
564            .bucket(&bucket_str)
565            .unwrap()
566            .put_object(&src_key)
567            .unwrap()
568            .body(bytes::Bytes::from(content))
569            .content_type("text/plain")
570            .send()
571            .await
572            .unwrap();
573
574        let bucket = BucketName::new(&bucket_str).unwrap();
575        let source_key = ObjectKey::new(&src_key).unwrap();
576
577        let output = client
578            .bucket(&bucket_str)
579            .unwrap()
580            .copy_object(&dest_key, &bucket, &source_key)
581            .unwrap()
582            .send()
583            .await
584            .unwrap();
585
586        assert!(!output.etag.is_empty());
587        assert!(!output.last_modified.is_empty());
588        eprintln!(
589            "COPY '{}' -> '{}' succeeded: etag={}",
590            src_key, dest_key, output.etag
591        );
592
593        let get_output = client
594            .bucket(&bucket_str)
595            .unwrap()
596            .get_object(&dest_key)
597            .unwrap()
598            .send()
599            .await
600            .unwrap();
601        assert_eq!(get_output.body.as_ref(), content.as_bytes());
602
603        client
604            .bucket(&bucket_str)
605            .unwrap()
606            .delete_object(&src_key)
607            .unwrap()
608            .send()
609            .await
610            .unwrap();
611        client
612            .bucket(&bucket_str)
613            .unwrap()
614            .delete_object(&dest_key)
615            .unwrap()
616            .send()
617            .await
618            .unwrap();
619    }
620}