rusty_s3/
bucket.rs

1use std::borrow::Cow;
2use std::error::Error as StdError;
3use std::fmt::{self, Display};
4
5use url::{ParseError, Url};
6
7use crate::actions::{
8    AbortMultipartUpload, CreateBucket, DeleteBucket, DeleteObject, GetObject, HeadBucket,
9    HeadObject, PutObject, UploadPart,
10};
11#[cfg(feature = "full")]
12use crate::actions::{
13    CompleteMultipartUpload, CreateMultipartUpload, DeleteObjects, ListObjectsV2, ListParts,
14};
15use crate::signing::util::percent_encode_path;
16use crate::Credentials;
17
18/// An S3 bucket
19///
20/// ## Path style url
21///
22/// ```rust
23/// # use rusty_s3::{Bucket, UrlStyle};
24/// let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url");
25/// let path_style = UrlStyle::Path;
26/// let name = "rusty-s3";
27/// let region = "eu-west-1";
28///
29/// let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host");
30/// assert_eq!(bucket.base_url().as_str(), "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/");
31/// assert_eq!(bucket.name(), "rusty-s3");
32/// assert_eq!(bucket.region(), "eu-west-1");
33/// assert_eq!(bucket.object_url("duck.jpg").expect("url is valid").as_str(), "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/duck.jpg");
34/// ```
35///
36/// ## Domain style url
37///
38/// ```rust
39/// # use rusty_s3::{Bucket, UrlStyle};
40/// let endpoint = "https://s3.dualstack.eu-west-1.amazonaws.com".parse().expect("endpoint is a valid Url");
41/// let path_style = UrlStyle::VirtualHost;
42/// let name = "rusty-s3";
43/// let region = "eu-west-1";
44///
45/// let bucket = Bucket::new(endpoint, path_style, name, region).expect("Url has a valid scheme and host");
46/// assert_eq!(bucket.base_url().as_str(), "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/");
47/// assert_eq!(bucket.name(), "rusty-s3");
48/// assert_eq!(bucket.region(), "eu-west-1");
49/// assert_eq!(bucket.object_url("duck.jpg").expect("url is valid").as_str(), "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/duck.jpg");
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Bucket {
53    base_url: Url,
54    name: Cow<'static, str>,
55    region: Cow<'static, str>,
56}
57
58/// The request url format of a S3 bucket.
59#[derive(Debug, Clone, Copy)]
60pub enum UrlStyle {
61    /// Requests will use "path-style" url: i.e:
62    /// `https://s3.<region>.amazonaws.com/<bucket>/<key>`.
63    ///
64    /// This style should be considered deprecated and is **NOT RECOMMENDED**.
65    /// Check [Amazon S3 Path Deprecation Plan](https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/)
66    /// for more informations.
67    Path,
68    /// Requests will use "virtual-hosted-style" urls, i.e:
69    /// `https://<bucket>.s3.<region>.amazonaws.com/<key>`.
70    VirtualHost,
71}
72
73#[allow(clippy::module_name_repetitions)]
74#[derive(Debug, Copy, Clone, PartialEq, Eq)]
75pub enum BucketError {
76    UnsupportedScheme,
77    MissingHost,
78    ParseError(ParseError),
79}
80
81impl From<ParseError> for BucketError {
82    fn from(error: ParseError) -> Self {
83        Self::ParseError(error)
84    }
85}
86
87impl Bucket {
88    /// Construct a new S3 bucket
89    ///
90    /// # Errors
91    ///
92    /// Returns a `BucketError` if the `endpoint` is not a valid url, or if the `endpoint` is missing the host.
93    pub fn new(
94        endpoint: Url,
95        path_style: UrlStyle,
96        name: impl Into<Cow<'static, str>>,
97        region: impl Into<Cow<'static, str>>,
98    ) -> Result<Self, BucketError> {
99        endpoint.host_str().ok_or(BucketError::MissingHost)?;
100
101        match endpoint.scheme() {
102            "http" | "https" => {}
103            _ => return Err(BucketError::UnsupportedScheme),
104        };
105
106        let name = name.into();
107        let region = region.into();
108
109        let base_url = base_url(endpoint, &name, path_style)?;
110
111        Ok(Self {
112            base_url,
113            name,
114            region,
115        })
116    }
117
118    /// Get the base url of this s3 `Bucket`
119    #[must_use]
120    pub const fn base_url(&self) -> &Url {
121        &self.base_url
122    }
123
124    /// Get the name of this `Bucket`
125    #[must_use]
126    pub fn name(&self) -> &str {
127        &self.name
128    }
129
130    /// Get the region of this `Bucket`
131    #[must_use]
132    pub fn region(&self) -> &str {
133        &self.region
134    }
135
136    /// Generate an url to an object of this `Bucket`
137    ///
138    /// This is not a signed url, it's just the starting point for
139    /// generating an url to an S3 object.
140    ///
141    /// # Errors
142    ///
143    /// Returns a `ParseError` if the object is not a valid path.
144    pub fn object_url(&self, object: &str) -> Result<Url, ParseError> {
145        let object: Cow<'_, str> = percent_encode_path(object).into();
146        self.base_url.join(&object)
147    }
148}
149
150fn base_url(mut endpoint: Url, name: &str, path_style: UrlStyle) -> Result<Url, ParseError> {
151    match path_style {
152        UrlStyle::Path => {
153            let path = format!("{name}/");
154            endpoint.join(&path)
155        }
156        UrlStyle::VirtualHost => {
157            let host = format!("{}.{}", name, endpoint.host_str().unwrap());
158            endpoint.set_host(Some(&host))?;
159            Ok(endpoint)
160        }
161    }
162}
163
164// === Bucket level actions ===
165
166impl Bucket {
167    /// Create a new bucket.
168    ///
169    /// See [`CreateBucket`] for more details.
170    #[must_use]
171    pub const fn create_bucket<'a>(&'a self, credentials: &'a Credentials) -> CreateBucket<'a> {
172        CreateBucket::new(self, credentials)
173    }
174
175    /// Delete a bucket.
176    ///
177    /// See [`DeleteBucket`] for more details.
178    #[must_use]
179    pub const fn delete_bucket<'a>(&'a self, credentials: &'a Credentials) -> DeleteBucket<'a> {
180        DeleteBucket::new(self, credentials)
181    }
182}
183
184// === Basic actions ===
185
186impl Bucket {
187    /// Retrieve an object's metadata from S3, using a `HEAD` request.
188    ///
189    /// See [`HeadObject`] for more details.
190    #[must_use]
191    pub const fn head_object<'a>(
192        &'a self,
193        credentials: Option<&'a Credentials>,
194        object: &'a str,
195    ) -> HeadObject<'a> {
196        HeadObject::new(self, credentials, object)
197    }
198
199    /// Retrieve an bucket's metadata from S3, using a `HEAD` request.
200    ///
201    /// See [`HeadBucket`] for more details.
202    #[must_use]
203    pub const fn head_bucket<'a>(&'a self, credentials: Option<&'a Credentials>) -> HeadBucket<'a> {
204        HeadBucket::new(self, credentials)
205    }
206
207    /// Retrieve an object from S3, using a `GET` request.
208    ///
209    /// See [`GetObject`] for more details.
210    #[must_use]
211    pub const fn get_object<'a>(
212        &'a self,
213        credentials: Option<&'a Credentials>,
214        object: &'a str,
215    ) -> GetObject<'a> {
216        GetObject::new(self, credentials, object)
217    }
218
219    /// List all objects in the bucket.
220    ///
221    /// See [`ListObjectsV2`] for more details.
222    #[cfg(feature = "full")]
223    #[must_use]
224    pub fn list_objects_v2<'a>(
225        &'a self,
226        credentials: Option<&'a Credentials>,
227    ) -> ListObjectsV2<'a> {
228        ListObjectsV2::new(self, credentials)
229    }
230
231    /// Upload a file to S3, using a `PUT` request.
232    ///
233    /// See [`PutObject`] for more details.
234    #[must_use]
235    pub const fn put_object<'a>(
236        &'a self,
237        credentials: Option<&'a Credentials>,
238        object: &'a str,
239    ) -> PutObject<'a> {
240        PutObject::new(self, credentials, object)
241    }
242
243    /// Delete an object from S3, using a `DELETE` request.
244    ///
245    /// See [`DeleteObject`] for more details.
246    #[must_use]
247    pub const fn delete_object<'a>(
248        &'a self,
249        credentials: Option<&'a Credentials>,
250        object: &'a str,
251    ) -> DeleteObject<'a> {
252        DeleteObject::new(self, credentials, object)
253    }
254
255    /// Delete multiple objects from S3 using a single `POST` request.
256    ///
257    /// See [`DeleteObjects`] for more details.
258    #[cfg(feature = "full")]
259    pub const fn delete_objects<'a, I>(
260        &'a self,
261        credentials: Option<&'a Credentials>,
262        objects: I,
263    ) -> DeleteObjects<'a, I> {
264        DeleteObjects::new(self, credentials, objects)
265    }
266}
267
268// === Multipart Upload ===
269
270impl Bucket {
271    /// Create a multipart upload.
272    ///
273    /// See [`CreateMultipartUpload`] for more details.
274    #[cfg(feature = "full")]
275    #[must_use]
276    pub const fn create_multipart_upload<'a>(
277        &'a self,
278        credentials: Option<&'a Credentials>,
279        object: &'a str,
280    ) -> CreateMultipartUpload<'a> {
281        CreateMultipartUpload::new(self, credentials, object)
282    }
283
284    /// Upload a part to a previously created multipart upload.
285    ///
286    /// See [`UploadPart`] for more details.
287    #[must_use]
288    pub const fn upload_part<'a>(
289        &'a self,
290        credentials: Option<&'a Credentials>,
291        object: &'a str,
292        part_number: u16,
293        upload_id: &'a str,
294    ) -> UploadPart<'a> {
295        UploadPart::new(self, credentials, object, part_number, upload_id)
296    }
297
298    /// Complete a multipart upload.
299    ///
300    /// See [`CompleteMultipartUpload`] for more details.
301    #[cfg(feature = "full")]
302    pub const fn complete_multipart_upload<'a, I>(
303        &'a self,
304        credentials: Option<&'a Credentials>,
305        object: &'a str,
306        upload_id: &'a str,
307        etags: I,
308    ) -> CompleteMultipartUpload<'a, I> {
309        CompleteMultipartUpload::new(self, credentials, object, upload_id, etags)
310    }
311
312    /// Abort multipart upload.
313    ///
314    /// See [`AbortMultipartUpload`] for more details.
315    #[must_use]
316    pub const fn abort_multipart_upload<'a>(
317        &'a self,
318        credentials: Option<&'a Credentials>,
319        object: &'a str,
320        upload_id: &'a str,
321    ) -> AbortMultipartUpload<'a> {
322        AbortMultipartUpload::new(self, credentials, object, upload_id)
323    }
324
325    /// Lists the parts that have been uploaded for a specific multipart upload.
326    ///
327    /// See [`ListParts`] for more details.
328    #[cfg(feature = "full")]
329    #[must_use]
330    pub const fn list_parts<'a>(
331        &'a self,
332        credentials: Option<&'a Credentials>,
333        object: &'a str,
334        upload_id: &'a str,
335    ) -> ListParts<'a> {
336        ListParts::new(self, credentials, object, upload_id)
337    }
338}
339
340impl Display for BucketError {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        match *self {
343            Self::UnsupportedScheme => f.write_str("unsupported Url scheme"),
344            Self::MissingHost => f.write_str("Url is missing the `host`"),
345            Self::ParseError(e) => e.fmt(f),
346        }
347    }
348}
349
350impl StdError for BucketError {}
351
352#[cfg(test)]
353mod tests {
354    use pretty_assertions::assert_eq;
355
356    use super::*;
357    #[cfg(feature = "full")]
358    use crate::actions::ObjectIdentifier;
359
360    #[test]
361    fn new_pathstyle() {
362        let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com"
363            .parse()
364            .unwrap();
365        let base_url: Url = "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/"
366            .parse()
367            .unwrap();
368        let name = "rusty-s3";
369        let region = "eu-west-1";
370        let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap();
371
372        assert_eq!(bucket.base_url(), &base_url);
373        assert_eq!(bucket.name(), name);
374        assert_eq!(bucket.region(), region);
375    }
376
377    #[test]
378    fn new_domainstyle() {
379        let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com"
380            .parse()
381            .unwrap();
382        let base_url: Url = "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com"
383            .parse()
384            .unwrap();
385        let name = "rusty-s3";
386        let region = "eu-west-1";
387        let bucket = Bucket::new(endpoint, UrlStyle::VirtualHost, name, region).unwrap();
388
389        assert_eq!(bucket.base_url(), &base_url);
390        assert_eq!(bucket.name(), name);
391        assert_eq!(bucket.region(), region);
392    }
393
394    #[test]
395    fn new_bad_scheme() {
396        let endpoint = "ftp://example.com/example".parse().unwrap();
397        let name = "rusty-s3";
398        let region = "eu-west-1";
399        assert_eq!(
400            Bucket::new(endpoint, UrlStyle::Path, name, region),
401            Err(BucketError::UnsupportedScheme)
402        );
403    }
404
405    #[test]
406    fn new_missing_host() {
407        let endpoint = "file:///home/something".parse().unwrap();
408        let name = "rusty-s3";
409        let region = "eu-west-1";
410        assert_eq!(
411            Bucket::new(endpoint, UrlStyle::Path, name, region),
412            Err(BucketError::MissingHost)
413        );
414    }
415
416    #[test]
417    fn object_url_pathstyle() {
418        let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com"
419            .parse()
420            .unwrap();
421        let name = "rusty-s3";
422        let region = "eu-west-1";
423        let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap();
424
425        let path_style = bucket.object_url("something/cat.jpg").unwrap();
426        assert_eq!(
427            "https://s3.dualstack.eu-west-1.amazonaws.com/rusty-s3/something/cat.jpg",
428            path_style.as_str()
429        );
430    }
431
432    #[test]
433    fn object_url_domainstyle() {
434        let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com"
435            .parse()
436            .unwrap();
437        let name = "rusty-s3";
438        let region = "eu-west-1";
439        let bucket = Bucket::new(endpoint, UrlStyle::VirtualHost, name, region).unwrap();
440
441        let domain_style = bucket.object_url("something/cat.jpg").unwrap();
442        assert_eq!(
443            "https://rusty-s3.s3.dualstack.eu-west-1.amazonaws.com/something/cat.jpg",
444            domain_style.as_str()
445        );
446    }
447
448    #[test]
449    fn all_actions() {
450        let endpoint: Url = "https://s3.dualstack.eu-west-1.amazonaws.com"
451            .parse()
452            .unwrap();
453
454        let name = "rusty-s3";
455        let region = "eu-west-1";
456        let bucket = Bucket::new(endpoint, UrlStyle::Path, name, region).unwrap();
457
458        let credentials = Credentials::new(
459            "AKIAIOSFODNN7EXAMPLE",
460            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
461        );
462
463        let _ = bucket.create_bucket(&credentials);
464        let _ = bucket.delete_bucket(&credentials);
465
466        let _ = bucket.head_object(Some(&credentials), "duck.jpg");
467        let _ = bucket.get_object(Some(&credentials), "duck.jpg");
468        #[cfg(feature = "full")]
469        let _ = bucket.list_objects_v2(Some(&credentials));
470        let _ = bucket.put_object(Some(&credentials), "duck.jpg");
471        let _ = bucket.delete_object(Some(&credentials), "duck.jpg");
472        #[cfg(feature = "full")]
473        let _ = bucket.delete_objects(Some(&credentials), std::iter::empty::<ObjectIdentifier>());
474
475        #[cfg(feature = "full")]
476        let _ = bucket.create_multipart_upload(Some(&credentials), "duck.jpg");
477        let _ = bucket.upload_part(Some(&credentials), "duck.jpg", 1, "abcd");
478        #[cfg(feature = "full")]
479        let _ = bucket.complete_multipart_upload(
480            Some(&credentials),
481            "duck.jpg",
482            "abcd",
483            ["1234"].iter().copied(),
484        );
485        let _ = bucket.abort_multipart_upload(Some(&credentials), "duck.jpg", "abcd");
486        #[cfg(feature = "full")]
487        let _ = bucket.list_parts(Some(&credentials), "duck.jpg", "abcd");
488    }
489}