Skip to main content

toolcraft_s3_kit/
client.rs

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