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