Skip to main content

toolcraft_s3_kit/
bucket_client.rs

1use std::{path::Path, sync::Arc};
2
3use bytes::Bytes;
4use toolcraft_request::HeaderMap;
5use toolcraft_utils::{presign_get_object, presign_put_object, sign_request};
6
7use crate::{
8    client::S3Client,
9    error::Result,
10    util::{ObjectInfo, check_status, parse_object_list, url_encode},
11};
12
13// ── Types ─────────────────────────────────────────────────────────────────────
14
15/// Object operations scoped to a specific bucket.
16///
17/// Constructed from a shared [`S3Client`]:
18/// ```rust,ignore
19/// let client = Arc::new(S3Client::new(endpoint, ak, sk, None)?);
20/// let bucket = BucketClient::new(Arc::clone(&client), "my-bucket");
21/// ```
22#[derive(Clone)]
23pub struct BucketClient {
24    inner: Arc<S3Client>,
25    bucket: String,
26}
27
28// ── Init ──────────────────────────────────────────────────────────────────────
29
30impl BucketClient {
31    pub fn new(client: Arc<S3Client>, bucket: impl Into<String>) -> Self {
32        Self {
33            inner: client,
34            bucket: bucket.into(),
35        }
36    }
37}
38
39// ── Object operations ─────────────────────────────────────────────────────────
40
41impl BucketClient {
42    pub async fn list_objects(&self, prefix: Option<&str>) -> Result<Vec<ObjectInfo>> {
43        let c = &self.inner;
44        let path = format!("/{}", self.bucket);
45        let query = match prefix {
46            Some(p) => format!("list-type=2&prefix={}", url_encode(p)),
47            None => "list-type=2".to_string(),
48        };
49        let auth = sign_request(
50            "GET",
51            &c.access_key,
52            &c.secret_key,
53            &c.host(),
54            &path,
55            &query,
56            Some(&c.region),
57        );
58
59        let resp = c
60            .http
61            .get(
62                &format!("{}?{}", c.url(&path), query),
63                None,
64                Some(c.signed_headers(&auth)?),
65            )
66            .await?;
67
68        let xml = check_status(resp).await?.text().await?;
69        parse_object_list(&xml)
70    }
71
72    /// Upload raw bytes as an object.
73    pub async fn upload_bytes(
74        &self,
75        key: &str,
76        data: Bytes,
77        content_type: Option<&str>,
78    ) -> Result<()> {
79        let c = &self.inner;
80        let url = presign_put_object(
81            &c.access_key,
82            &c.secret_key,
83            &self.bucket,
84            key,
85            Some(&c.region),
86            c.base_url.as_str(),
87            None,
88        );
89
90        let mut headers = HeaderMap::new();
91        if let Some(ct) = content_type {
92            headers.insert("content-type", ct.to_string())?;
93        }
94        let headers = if headers.inner().is_empty() {
95            None
96        } else {
97            Some(headers)
98        };
99        check_status(c.http.put_bytes(&url, data, headers).await?)
100            .await
101            .map(|_| ())
102    }
103
104    /// Upload a local file to S3, returning uploaded bytes length.
105    pub async fn upload_local_file<P: AsRef<Path>>(
106        &self,
107        key: &str,
108        local_path: P,
109        content_type: Option<&str>,
110    ) -> Result<u64> {
111        let bytes = tokio::fs::read(local_path.as_ref()).await?;
112        let size = bytes.len() as u64;
113        self.upload_bytes(key, Bytes::from(bytes), content_type)
114            .await?;
115        Ok(size)
116    }
117
118    /// Backward-compatible alias. Prefer [`BucketClient::upload_bytes`].
119    pub async fn upload_file(
120        &self,
121        key: &str,
122        data: Bytes,
123        content_type: Option<&str>,
124    ) -> Result<()> {
125        self.upload_bytes(key, data, content_type).await
126    }
127
128    pub async fn download_object(&self, key: &str) -> Result<Bytes> {
129        let c = &self.inner;
130        let url = presign_get_object(
131            &c.access_key,
132            &c.secret_key,
133            &self.bucket,
134            key,
135            Some(&c.region),
136            c.base_url.as_str(),
137            None,
138        );
139
140        let resp = check_status(c.http.get(&url, None, None).await?).await?;
141        Ok(resp.bytes().await?)
142    }
143
144    pub async fn delete_object(&self, key: &str) -> Result<()> {
145        let c = &self.inner;
146        let path = format!("/{}/{}", self.bucket, key.trim_start_matches('/'));
147        let auth = sign_request(
148            "DELETE",
149            &c.access_key,
150            &c.secret_key,
151            &c.host(),
152            &path,
153            "",
154            Some(&c.region),
155        );
156
157        let resp = c
158            .http
159            .delete(&c.url(&path), Some(c.signed_headers(&auth)?))
160            .await?;
161
162        check_status(resp).await.map(|_| ())
163    }
164
165    /// Generate a presigned PUT URL for direct client-side upload.
166    pub fn presign_upload(&self, key: &str, expires_secs: Option<u64>) -> String {
167        let c = &self.inner;
168        presign_put_object(
169            &c.access_key,
170            &c.secret_key,
171            &self.bucket,
172            key,
173            Some(&c.region),
174            c.base_url.as_str(),
175            expires_secs,
176        )
177    }
178}