Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

actions: add CopyObject action #47

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ More examples can be found in the examples directory on GitHub.
* [`HeadObject`][headobject]
* [`GetObject`][getobject]
* [`PutObject`][putobject]
* [`CopyObject`][copyobject]
* [`DeleteObject`][deleteobject]
* [`DeleteObjects`][deleteobjects]
* [`ListObjectsV2`][listobjectsv2]
Expand All @@ -67,6 +68,7 @@ More examples can be found in the examples directory on GitHub.
[createbucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html
[deletebucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html
[createmultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
[copyobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
[deleteobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html
[deleteobjects]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
[getobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
Expand Down
164 changes: 164 additions & 0 deletions src/actions/copy_object.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
use std::borrow::{Borrow, Cow};
use std::iter;
use std::time::Duration;

use time::OffsetDateTime;
use url::Url;

use super::S3Action;
use crate::actions::Method;
use crate::signing::sign;
use crate::sorting_iter::SortingIterator;
use crate::{Bucket, Credentials, Map};

/// Create a copy of an object that is already stored in S3, using a `PUT` request.
///
/// Note that:
/// * only objects up to 5 GB can be copied using this method
/// * even if the server returns a 200 response the copy might have failed
///
/// Find out more about CopyObject from the [AWS API Reference][api]
///
/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
#[derive(Debug, Clone)]
pub struct CopyObject<'a> {
bucket: &'a Bucket,
credentials: Option<&'a Credentials>,
src_object: &'a str,
dst_object: &'a str,
prepend_bucket: bool,

query: Map<'a>,
headers: Map<'a>,
}

impl<'a> CopyObject<'a> {
#[inline]
pub fn new(
bucket: &'a Bucket,
credentials: Option<&'a Credentials>,
src_object: &'a str,
dst_object: &'a str,
prepend_bucket: bool,
) -> Self {
Self {
bucket,
credentials,
src_object,
dst_object,
prepend_bucket,

query: Map::new(),
headers: Map::new(),
}
}
}

impl<'a> S3Action<'a> for CopyObject<'a> {
const METHOD: Method = Method::Put;

fn sign(&self, expires_in: Duration) -> Url {
let now = OffsetDateTime::now_utc();
self.sign_with_time(expires_in, &now)
}

fn query_mut(&mut self) -> &mut Map<'a> {
&mut self.query
}

fn headers_mut(&mut self) -> &mut Map<'a> {
&mut self.headers
}

fn sign_with_time(&self, expires_in: Duration, time: &OffsetDateTime) -> Url {
let url = self.bucket.object_url(self.dst_object).unwrap();
let copy_source = if self.prepend_bucket {
Cow::from(format!("{}/{}", self.bucket.name(), self.src_object))
} else {
Cow::from(self.src_object)
};
let query = SortingIterator::new(
iter::once(("x-amz-copy-source", copy_source.borrow())),
self.query.iter(),
);

match self.credentials {
Some(credentials) => sign(
time,
Method::Put,
url,
credentials.key(),
credentials.secret(),
credentials.token(),
self.bucket.region(),
expires_in.as_secs(),
query,
self.headers.iter(),
),
None => crate::signing::util::add_query_params(url, query),
}
}
}

#[cfg(test)]
mod tests {
use time::OffsetDateTime;

use pretty_assertions::assert_eq;

use super::*;
use crate::{Bucket, Credentials, UrlStyle};

#[test]
fn aws_example() {
// Fri, 24 May 2013 00:00:00 GMT
let date = OffsetDateTime::from_unix_timestamp(1369353600).unwrap();
let expires_in = Duration::from_secs(86400);

let endpoint = "https://s3.amazonaws.com".parse().unwrap();
let bucket = Bucket::new(
endpoint,
UrlStyle::VirtualHost,
"examplebucket",
"us-east-1",
)
.unwrap();
let credentials = Credentials::new(
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
);

let action = CopyObject::new(
&bucket,
Some(&credentials),
"test.txt",
"test_copy.txt",
true,
);

let url = action.sign_with_time(expires_in, &date);
let expected = "https://examplebucket.s3.amazonaws.com/test_copy.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&x-amz-copy-source=examplebucket%2Ftest.txt&X-Amz-Signature=760326dbb90c424f6b5dcfa5f8473754f44cb4c05c173416feb1b9306dc64d35";

assert_eq!(expected, url.as_str());
}

#[test]
fn anonymous_custom_query() {
let expires_in = Duration::from_secs(86400);

let endpoint = "https://s3.amazonaws.com".parse().unwrap();
let bucket = Bucket::new(
endpoint,
UrlStyle::VirtualHost,
"examplebucket",
"us-east-1",
)
.unwrap();

let action = CopyObject::new(&bucket, None, "test.txt", "test_copy.txt", true);
let url = action.sign(expires_in);
let expected = "https://examplebucket.s3.amazonaws.com/test_copy.txt?x-amz-copy-source=examplebucket%2Ftest.txt";

assert_eq!(expected, url.as_str());
}
}
2 changes: 2 additions & 0 deletions src/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::time::Duration;

use url::Url;

pub use self::copy_object::CopyObject;
pub use self::create_bucket::CreateBucket;
pub use self::delete_bucket::DeleteBucket;
pub use self::delete_object::DeleteObject;
Expand All @@ -25,6 +26,7 @@ pub use self::multipart_upload::upload::UploadPart;
pub use self::put_object::PutObject;
use crate::{Map, Method};

mod copy_object;
mod create_bucket;
mod delete_bucket;
mod delete_object;
Expand Down
30 changes: 28 additions & 2 deletions src/bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use std::fmt::{self, Display};
use url::{ParseError, Url};

use crate::actions::{
AbortMultipartUpload, CreateBucket, DeleteBucket, DeleteObject, GetObject, HeadObject,
PutObject, UploadPart,
AbortMultipartUpload, CopyObject, CreateBucket, DeleteBucket, DeleteObject, GetObject,
HeadObject, PutObject, UploadPart,
};
#[cfg(feature = "full")]
use crate::actions::{
Expand Down Expand Up @@ -207,6 +207,32 @@ impl Bucket {
PutObject::new(self, credentials, object)
}

/// Create a copy of an object in S3 in this bucket, using a `PUT` request.
///
/// See [`CopyObject`] for more details.
pub fn copy_object<'a>(
&'a self,
credentials: Option<&'a Credentials>,
src_object: &'a str,
dst_object: &'a str,
) -> CopyObject<'a> {
CopyObject::new(self, credentials, src_object, dst_object, true)
}

/// Create a copy of an object in S3 in a potentially different bucket, using a `PUT` request.
///
/// Requires a bucket name to be prepended to `src_object`, separated by a slash.
///
/// See [`CopyObject`] for more details.
pub fn copy_object_from_bucket<'a>(
&'a self,
credentials: Option<&'a Credentials>,
src_object: &'a str,
dst_object: &'a str,
) -> CopyObject<'a> {
CopyObject::new(self, credentials, src_object, dst_object, false)
}

/// Delete an object from S3, using a `DELETE` request.
///
/// See [`DeleteObject`] for more details.
Expand Down
124 changes: 124 additions & 0 deletions tests/copy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::time::Duration;

use rusty_s3::actions::{ListObjectsV2, S3Action};

mod common;

#[tokio::test]
async fn test_copy() {
let (bucket, credentials, client) = common::bucket().await;
let (bucket_copy, credentials_copy, client_copy) = common::bucket().await;

let action = bucket.list_objects_v2(Some(&credentials));
let url = action.sign(Duration::from_secs(60));
let resp = client
.get(url)
.send()
.await
.expect("send ListObjectsV2")
.error_for_status()
.expect("ListObjectsV2 unexpected status code");
let text = resp.text().await.expect("ListObjectsV2 read respose body");
let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response");

assert!(list.contents.is_empty());

assert_eq!(list.max_keys, 4500);
assert!(list.common_prefixes.is_empty());
assert!(list.next_continuation_token.is_none());
assert!(list.start_after.is_none());

let body = vec![b'r'; 1024];

let action = bucket.put_object(Some(&credentials), "test.txt");
let url = action.sign(Duration::from_secs(60));
client
.put(url)
.body(body.clone())
.send()
.await
.expect("send PutObject")
.error_for_status()
.expect("PutObject unexpected status code");

// Copy same bucket
let action = bucket.copy_object(Some(&credentials), "test.txt", "test_copy.txt");
let url = action.sign(Duration::from_secs(60));
client
.put(url)
.send()
.await
.expect("send CopyObject")
.error_for_status()
.expect("CopyObject unexpected status code");

let action = bucket.list_objects_v2(Some(&credentials));
let url = action.sign(Duration::from_secs(60));
let resp = client
.get(url)
.send()
.await
.expect("send ListObjectsV2")
.error_for_status()
.expect("ListObjectsV2 unexpected status code");
let text = resp.text().await.expect("ListObjectsV2 read respose body");
let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response");
assert_eq!(list.contents.len(), 2);

// Copy different bucket
let dest_object = format!("{}/{}", bucket.name(), "test.txt");
let action =
bucket_copy.copy_object_from_bucket(Some(&credentials_copy), &dest_object, "test_copy.txt");
let url = action.sign(Duration::from_secs(60));
client_copy
.put(url)
.send()
.await
.expect("send CopyObject")
.error_for_status()
.expect("CopyObject unexpected status code");

let action = bucket_copy.list_objects_v2(Some(&credentials_copy));
let url = action.sign(Duration::from_secs(60));
let resp = client_copy
.get(url)
.send()
.await
.expect("send ListObjectsV2")
.error_for_status()
.expect("ListObjectsV2 unexpected status code");
let text = resp.text().await.expect("ListObjectsV2 read respose body");
let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response");
assert_eq!(list.contents.len(), 1);

// Check length
let action = bucket.head_object(Some(&credentials), "test_copy.txt");
let url = action.sign(Duration::from_secs(60));
let resp = client
.head(url)
.send()
.await
.expect("send HeadObject")
.error_for_status()
.expect("HeadObject unexpected status code");
let content_length = resp
.headers()
.get("content-length")
.expect("Content-Length header")
.to_str()
.expect("Content-Length to_str()");
assert_eq!(content_length, "1024");

// Check content
let action = bucket.get_object(Some(&credentials), "test_copy.txt");
let url = action.sign(Duration::from_secs(60));
let resp = client
.get(url)
.send()
.await
.expect("send GetObject")
.error_for_status()
.expect("GetObject unexpected status code");
let bytes = resp.bytes().await.expect("GetObject read response body");
assert_eq!(body, bytes);
}