Skip to main content

ossify/
lib.rs

1pub mod body;
2mod credential;
3pub mod credentials;
4mod error;
5pub mod ops;
6mod query_auth_option;
7pub mod response;
8mod ser;
9mod utils;
10
11use std::borrow::Cow;
12use std::collections::HashSet;
13use std::time::Duration;
14
15use http::HeaderMap;
16use http::header::HOST;
17use serde::Serialize;
18use tracing::trace;
19use url::Url;
20
21pub use self::body::MakeBody;
22use self::credential::SignContext;
23use self::credentials::{
24    CredentialsProvider,
25    DefaultCredentialsChain,
26    DynCredentialsProvider,
27    StaticCredentialsProvider,
28};
29pub use self::error::{Error, Result};
30pub use self::query_auth_option::{QueryAuthOptions, QueryAuthOptionsBuilder};
31pub use self::response::ResponseProcessor;
32pub use self::utils::escape_path;
33
34/// Alias for a type-erased error type.
35pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
36
37#[derive(Debug, Clone)]
38pub struct Prepared<Q = (), B = ()> {
39    /// The HTTP Method used for this operation (e.g. GET, PATCH, DELETE)
40    pub method: http::Method,
41    /// The Key for this operation
42    pub key: Option<String>,
43    /// Additional headers used for signature calculation
44    pub additional_headers: HashSet<String>,
45    /// The headers for the request, if any
46    pub headers: Option<HeaderMap>,
47    /// The query string for the request, if any
48    pub query: Option<Q>,
49    /// The body of the request, if any
50    pub body: Option<B>,
51}
52
53impl<Q, B> Default for Prepared<Q, B> {
54    fn default() -> Self {
55        Self {
56            method: http::Method::GET,
57            key: None,
58            additional_headers: HashSet::new(),
59            headers: None,
60            query: None,
61            body: None,
62        }
63    }
64}
65
66pub trait Ops: Sized {
67    const PRODUCT: &'static str = "oss";
68    const USE_BUCKET: bool = true;
69
70    type Query;
71    type Body: MakeBody;
72    type Response;
73
74    fn prepare(self) -> Result<Prepared<Self::Query, <Self::Body as MakeBody>::Body>>;
75}
76
77pub(crate) trait Request<P> {
78    type Response;
79
80    fn request(&self, ops: P) -> impl Future<Output = Result<Self::Response>>;
81
82    fn presign(
83        &self,
84        ops: P,
85        public: bool,
86        query_auth_options: Option<QueryAuthOptions>,
87    ) -> impl Future<Output = Result<String>>;
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum UrlStyle {
92    #[default]
93    VirtualHosted,
94    Path,
95    CName,
96}
97
98/// Configuration for the API client.
99/// Allows users to customize its behaviors.
100pub struct ClientConfig {
101    /// The maximum time limit for an request.
102    pub http_timeout: Duration,
103    /// A default set of HTTP headers which will be sent with each API request.
104    pub default_headers: http::HeaderMap,
105    /// The URL style to use for the API client that uses internal endpoint.
106    pub url_style: UrlStyle,
107    /// The URL style to use for the API client that uses public endpoint.
108    pub public_url_style: UrlStyle,
109}
110
111impl Default for ClientConfig {
112    fn default() -> Self {
113        ClientConfig {
114            http_timeout: Duration::from_secs(30),
115            default_headers: http::HeaderMap::default(),
116            url_style: UrlStyle::default(),
117            public_url_style: UrlStyle::default(),
118        }
119    }
120}
121
122#[derive(Debug, Clone)]
123pub struct Client {
124    http_client: reqwest::Client,
125
126    raw_internal_host: String,
127    raw_internal_scheme: String,
128    raw_public_host: String,
129    raw_public_scheme: String,
130    region: String,
131    bucket: String,
132    url_style: UrlStyle,
133    public_url_style: UrlStyle,
134    credentials_provider: DynCredentialsProvider,
135}
136
137impl Client {
138    pub fn builder() -> ClientBuilder {
139        ClientBuilder::new()
140    }
141
142    fn build_url<'a>(
143        &'a self,
144        bucket: Option<Cow<'a, str>>,
145        key: Option<Cow<'a, str>>,
146        public: bool,
147    ) -> (Cow<'a, str>, Cow<'a, str>) {
148        let host = if public {
149            self.raw_public_host.as_str()
150        } else {
151            self.raw_internal_host.as_str()
152        };
153
154        let url_style = if public {
155            self.public_url_style
156        } else {
157            self.url_style
158        };
159
160        let (host, paths) = match (bucket, url_style) {
161            (Some(bucket_name), UrlStyle::VirtualHosted) => {
162                (Cow::Owned(format!("{bucket_name}.{host}")), None)
163            },
164            (Some(bucket_name), UrlStyle::Path) => {
165                let mut paths = Vec::with_capacity(2);
166                paths.push(bucket_name);
167                if key.is_none() {
168                    paths.push(Cow::Borrowed(""));
169                }
170                (Cow::Borrowed(host), Some(paths))
171            },
172            (None, _) | (Some(_), UrlStyle::CName) => (Cow::Borrowed(host), None),
173        };
174
175        let path = match (paths, key.as_ref().map(|k| k.trim().trim_start_matches('/'))) {
176            (Some(paths), Some(key_str)) => {
177                let mut paths = paths.clone();
178                paths.push(escape_path(key_str).into());
179                Cow::Owned(format!("/{}", paths.join("/")))
180            },
181            (Some(paths), None) => Cow::Owned(format!("/{}", paths.join("/"))),
182            (None, Some(key_str)) => Cow::Owned(format!("/{}", escape_path(key_str))),
183            (None, None) => Cow::Borrowed("/"),
184        };
185
186        (host, path)
187    }
188
189    async fn prepare_request<P>(
190        &self,
191        ops: P,
192        public: bool,
193        query_auth_options: Option<QueryAuthOptions>,
194    ) -> Result<reqwest::Request>
195    where
196        P: Ops + Send + 'static,
197        P::Query: Serialize + Clone + Send,
198        P::Response: ResponseProcessor + Send,
199        P::Body: MakeBody + Send,
200    {
201        let Prepared {
202            method,
203            key,
204            additional_headers,
205            headers: extra_headers,
206            query,
207            body,
208        } = ops.prepare()?;
209
210        let bucket = P::USE_BUCKET.then_some(Cow::Borrowed(self.bucket.as_str()));
211
212        // Build the request
213        let (host, path) = self.build_url(bucket.clone(), key.as_deref().map(Cow::Borrowed), public);
214        let scheme = if public {
215            &self.raw_public_scheme
216        } else {
217            &self.raw_internal_scheme
218        };
219        let request_url = format!("{scheme}://{host}{path}");
220        let mut request = self.http_client.request(method.clone(), request_url).build()?;
221
222        let headers = request.headers_mut();
223        // Fill the headers if any
224        if let Some(extra_headers) = extra_headers {
225            headers.extend(extra_headers);
226        }
227
228        headers.insert(HOST, host.parse()?);
229
230        // Prepare additional headers
231        let additional_headers = additional_headers
232            .into_iter()
233            .map(Cow::Owned)
234            .collect::<HashSet<Cow<str>>>();
235
236        // Fill the body if any
237        if let Some(body) = body {
238            P::Body::make_body(body, &mut request)?;
239        }
240
241        // Prepare sign context
242        let sign_context = SignContext {
243            region: Cow::Borrowed(&self.region),
244            product: Cow::Borrowed(P::PRODUCT),
245            bucket,
246            key: key.as_deref().map(Cow::Borrowed),
247            query: query.as_ref(),
248            additional_headers,
249        };
250
251        // Resolve credentials (may trigger STS call / file reads)
252        let credentials = self.credentials_provider.get_credentials().await?;
253
254        // Authenticate the request
255        credential::auth_to(&credentials, &mut request, sign_context, query_auth_options)?;
256
257        Ok(request)
258    }
259}
260
261impl<P> Request<P> for Client
262where
263    P: Ops + Send + 'static,
264    P::Query: Clone + Serialize + Send,
265    P::Response: ResponseProcessor + Send,
266    P::Body: MakeBody + Send,
267{
268    type Response = <P::Response as ResponseProcessor>::Output;
269
270    async fn request(&self, ops: P) -> Result<Self::Response> {
271        let request = self.prepare_request(ops, false, None).await?;
272
273        // Send the request
274        trace!("Sending request: {request:?}");
275        let resp = self.http_client.execute(request).await?;
276
277        // Parse the response
278        P::Response::from_response(resp).await
279    }
280
281    async fn presign(
282        &self,
283        ops: P,
284        public: bool,
285        query_auth_options: Option<QueryAuthOptions>,
286    ) -> Result<String> {
287        let request = self.prepare_request(ops, public, query_auth_options).await?;
288
289        let sign_url = request.url().to_string();
290        Ok(sign_url)
291    }
292}
293
294pub struct ClientBuilder {
295    config: ClientConfig,
296    endpoint: Option<String>,
297    public_endpoint: Option<String>,
298    region: Option<String>,
299    bucket: Option<String>,
300    access_key_id: Option<String>,
301    access_key_secret: Option<String>,
302    security_token: Option<String>,
303    credentials_provider: Option<DynCredentialsProvider>,
304}
305
306impl ClientBuilder {
307    pub fn new() -> Self {
308        Self {
309            config: ClientConfig::default(),
310            endpoint: None,
311            public_endpoint: None,
312            region: None,
313            bucket: None,
314            access_key_id: None,
315            access_key_secret: None,
316            security_token: None,
317            credentials_provider: None,
318        }
319    }
320
321    /// Set the OSS endpoint URL
322    pub fn endpoint<T: AsRef<str>>(mut self, endpoint: T) -> Self {
323        self.endpoint = Some(endpoint.as_ref().to_string());
324        self
325    }
326
327    /// Set the public OSS endpoint URL (optional, defaults to endpoint if not set)
328    pub fn public_endpoint<T: AsRef<str>>(mut self, public_endpoint: T) -> Self {
329        self.public_endpoint = Some(public_endpoint.as_ref().to_string());
330        self
331    }
332
333    /// Set the OSS region
334    pub fn region<T: AsRef<str>>(mut self, region: T) -> Self {
335        self.region = Some(region.as_ref().to_string());
336        self
337    }
338
339    /// Set the bucket name
340    pub fn bucket<T: AsRef<str>>(mut self, bucket: T) -> Self {
341        self.bucket = Some(bucket.as_ref().to_string());
342        self
343    }
344
345    /// Set the access key ID for authentication.
346    ///
347    /// When combined with [`Self::access_key_secret`] (and optionally
348    /// [`Self::security_token`]) this installs a
349    /// [`StaticCredentialsProvider`]. For dynamic credentials (RRSA, ECS RAM
350    /// role, …) use [`Self::credentials_provider`] instead.
351    pub fn access_key_id<T: AsRef<str>>(mut self, access_key_id: T) -> Self {
352        self.access_key_id = Some(access_key_id.as_ref().to_string());
353        self
354    }
355
356    /// Set the access key secret for authentication. See
357    /// [`Self::access_key_id`].
358    pub fn access_key_secret<T: AsRef<str>>(mut self, access_key_secret: T) -> Self {
359        self.access_key_secret = Some(access_key_secret.as_ref().to_string());
360        self
361    }
362
363    /// Set the security token (optional, for temporary STS credentials
364    /// supplied out-of-band).
365    pub fn security_token<T: AsRef<str>>(mut self, security_token: T) -> Self {
366        self.security_token = Some(security_token.as_ref().to_string());
367        self
368    }
369
370    /// Use a custom [`CredentialsProvider`]. This takes precedence over
371    /// [`Self::access_key_id`] / [`Self::access_key_secret`] /
372    /// [`Self::security_token`].
373    ///
374    /// For RRSA (RAM Roles for Service Accounts) pass an instance of
375    /// [`credentials::RrsaCredentialsProvider`]; for a zero-config setup that
376    /// also reads credentials from environment variables use
377    /// [`credentials::DefaultCredentialsChain`].
378    pub fn credentials_provider<P>(mut self, provider: P) -> Self
379    where
380        P: CredentialsProvider + 'static,
381    {
382        self.credentials_provider = Some(DynCredentialsProvider::new(provider));
383        self
384    }
385
386    /// Set the HTTP timeout for requests
387    pub fn http_timeout(mut self, timeout: Duration) -> Self {
388        self.config.http_timeout = timeout;
389        self
390    }
391
392    /// Set default headers to be sent with each request
393    pub fn default_headers(mut self, headers: http::HeaderMap) -> Self {
394        self.config.default_headers = headers;
395        self
396    }
397
398    /// Set the URL style for requests that use internal endpoint
399    pub fn url_style(mut self, style: UrlStyle) -> Self {
400        self.config.url_style = style;
401        self
402    }
403
404    /// Set the URL style for requests that use public endpoint
405    pub fn public_url_style(mut self, style: UrlStyle) -> Self {
406        self.config.public_url_style = style;
407        self
408    }
409
410    /// Build the Client with the configured parameters
411    pub fn build(self) -> Result<Client> {
412        // Validate required fields
413        let endpoint = self
414            .endpoint
415            .ok_or_else(|| Error::InvalidArgument("endpoint is required".to_string()))?;
416        let region = self
417            .region
418            .ok_or_else(|| Error::InvalidArgument("region is required".to_string()))?;
419        let bucket = self
420            .bucket
421            .ok_or_else(|| Error::InvalidArgument("bucket is required".to_string()))?;
422
423        // Build HTTP client
424        let http_client = reqwest::Client::builder()
425            .default_headers(self.config.default_headers)
426            .timeout(self.config.http_timeout)
427            .build()?;
428
429        // Parse endpoint URL
430        let endpoint_url = Url::parse(&endpoint)?;
431        let raw_internal_host = endpoint_url.host_str().ok_or(Error::MissingHost)?.to_owned();
432        let raw_internal_scheme = endpoint_url.scheme().to_owned();
433
434        // Parse public endpoint URL (use internal endpoint if not specified)
435        let public_endpoint_str = self.public_endpoint.as_ref().unwrap_or(&endpoint);
436        let public_endpoint_url = Url::parse(public_endpoint_str)?;
437        let raw_public_host = public_endpoint_url
438            .host_str()
439            .ok_or(Error::MissingHost)?
440            .to_owned();
441        let raw_public_scheme = public_endpoint_url.scheme().to_owned();
442
443        // Resolve credentials provider:
444        // 1. explicit provider wins
445        // 2. explicit AK/SK (+ optional token) falls back to a StaticCredentialsProvider
446        // 3. otherwise: DefaultCredentialsChain (env vars, RRSA, …)
447        let credentials_provider = if let Some(provider) = self.credentials_provider {
448            provider
449        } else {
450            match (self.access_key_id, self.access_key_secret) {
451                (Some(ak), Some(sk)) => {
452                    let provider = if let Some(token) = self.security_token {
453                        StaticCredentialsProvider::with_security_token(ak, sk, token)
454                    } else {
455                        StaticCredentialsProvider::new(ak, sk)
456                    };
457                    DynCredentialsProvider::new(provider)
458                },
459                _ => DynCredentialsProvider::new(DefaultCredentialsChain::with_http_client(
460                    http_client.clone(),
461                )),
462            }
463        };
464
465        Ok(Client {
466            region,
467            bucket,
468            raw_internal_host,
469            raw_internal_scheme,
470            raw_public_host,
471            raw_public_scheme,
472            url_style: self.config.url_style,
473            public_url_style: self.config.public_url_style,
474            credentials_provider,
475            http_client,
476        })
477    }
478}
479
480impl Default for ClientBuilder {
481    fn default() -> Self {
482        Self::new()
483    }
484}