Skip to main content

toolcraft_s3_kit/
client.rs

1use reqwest::Client;
2use toolcraft_utils::{DEFAULT_REGION, sign_request};
3use url::Url;
4
5use crate::{
6    error::{Error, Result},
7    util::{check_status, parse_bucket_names},
8};
9
10// ── Types ─────────────────────────────────────────────────────────────────────
11
12pub struct S3Client {
13    pub(crate) access_key: String,
14    pub(crate) secret_key: String,
15    pub(crate) base_url: Url,
16    pub(crate) region: String,
17    pub(crate) http: Client,
18}
19
20// ── Init ──────────────────────────────────────────────────────────────────────
21
22impl S3Client {
23    pub fn new(
24        endpoint: &str,
25        access_key: &str,
26        secret_key: &str,
27        region: Option<&str>,
28    ) -> Result<Self> {
29        let base_url = Url::parse(endpoint)?;
30        let http = Client::builder()
31            .build()
32            .map_err(|e| Error::Message(e.to_string().into()))?;
33        Ok(Self {
34            access_key: access_key.to_string(),
35            secret_key: secret_key.to_string(),
36            base_url,
37            region: region.unwrap_or(DEFAULT_REGION).to_string(),
38            http,
39        })
40    }
41}
42
43// ── Bucket management ─────────────────────────────────────────────────────────
44
45impl S3Client {
46    pub async fn create_bucket(&self, bucket: &str) -> Result<()> {
47        let path = format!("/{bucket}");
48        let auth = sign_request(
49            "PUT",
50            &self.access_key,
51            &self.secret_key,
52            &self.host(),
53            &path,
54            "",
55            Some(&self.region),
56        );
57
58        let body = if self.region != "us-east-1" {
59            format!(
60                "<CreateBucketConfiguration><LocationConstraint>{}</LocationConstraint></\
61                 CreateBucketConfiguration>",
62                self.region,
63            )
64        } else {
65            String::new()
66        };
67
68        let resp = self
69            .http
70            .put(self.url(&path))
71            .header("host", self.host())
72            .header("x-amz-date", &auth.x_amz_date)
73            .header("x-amz-content-sha256", &auth.x_amz_content_sha256)
74            .header("authorization", &auth.authorization)
75            .body(body)
76            .send()
77            .await?;
78
79        check_status(resp).await.map(|_| ())
80    }
81
82    pub async fn delete_bucket(&self, bucket: &str) -> Result<()> {
83        let path = format!("/{bucket}");
84        let auth = sign_request(
85            "DELETE",
86            &self.access_key,
87            &self.secret_key,
88            &self.host(),
89            &path,
90            "",
91            Some(&self.region),
92        );
93
94        let resp = self
95            .http
96            .delete(self.url(&path))
97            .header("host", self.host())
98            .header("x-amz-date", &auth.x_amz_date)
99            .header("x-amz-content-sha256", &auth.x_amz_content_sha256)
100            .header("authorization", &auth.authorization)
101            .send()
102            .await?;
103
104        check_status(resp).await.map(|_| ())
105    }
106
107    pub async fn list_buckets(&self) -> Result<Vec<String>> {
108        let auth = sign_request(
109            "GET",
110            &self.access_key,
111            &self.secret_key,
112            &self.host(),
113            "/",
114            "",
115            Some(&self.region),
116        );
117
118        let resp = self
119            .http
120            .get(self.url("/"))
121            .header("host", self.host())
122            .header("x-amz-date", &auth.x_amz_date)
123            .header("x-amz-content-sha256", &auth.x_amz_content_sha256)
124            .header("authorization", &auth.authorization)
125            .send()
126            .await?;
127
128        let xml = check_status(resp).await?.text().await?;
129        parse_bucket_names(&xml)
130    }
131}
132
133// ── Private helpers ───────────────────────────────────────────────────────────
134
135impl S3Client {
136    pub(crate) fn host(&self) -> String {
137        let host = self.base_url.host_str().unwrap_or_default();
138        match self.base_url.port() {
139            Some(port) => format!("{host}:{port}"),
140            None => host.to_string(),
141        }
142    }
143
144    pub(crate) fn url(&self, path: &str) -> String {
145        format!("{}://{}{}", self.base_url.scheme(), self.host(), path)
146    }
147}