1use 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(®ion_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}