Skip to main content

clevercloud_sdk/
lib.rs

1//! # Clever-Cloud Sdk
2//!
3//! This module provides a client and structures to interact with clever-cloud
4//! api.
5
6use std::{fmt::Debug, future::Future};
7
8use ::oauth10a::{
9    client::{Client as OAuthClient, ClientError as OAuthClientError},
10    credentials::Credentials as OAuthCredentials,
11    execute::ExecuteRequest,
12    reqwest::{self, Method, StatusCode},
13    rest::{ErrorResponse, RestClient as OAuthRestClient, RestError},
14};
15use serde::{Deserialize, Serialize, de::DeserializeOwned};
16
17pub mod v2;
18pub mod v4;
19
20// -----------------------------------------------------------------------------
21// Exports
22//
23// oauth10a 3.0.0 reworked its public API (generic `RestClient<X>` returning a
24// nested `Result<Result<O, ErrorResponse<E>>, RestError<_>>`, credentials and
25// client split across modules). To avoid rippling that change through every
26// `v2`/`v4` module and through downstream consumers, this crate keeps the
27// historical surface: a `ClientError`, and `Request`/`RestClient` traits whose
28// methods return a flat `Result<T, ClientError>`. The `oauth10a` module below
29// re-exports them so `clevercloud_sdk::oauth10a::{ClientError, reqwest, ..}`
30// keeps working.
31
32/// Compatibility facade exposing the historical `oauth10a::client` surface.
33pub mod oauth10a {
34    pub use ::oauth10a::{reqwest, url};
35
36    pub use crate::{ClientError, Request, ResponseError, RestClient};
37}
38
39// -----------------------------------------------------------------------------
40// Constants
41
42pub const PUBLIC_ENDPOINT: &str = "https://api.clever-cloud.com";
43pub const PUBLIC_API_BRIDGE_ENDPOINT: &str = "https://api-bridge.clever-cloud.com";
44
45// Consumer key and secret reported here are one from the clever-tools and is
46// available publicly.
47// the disclosure of these tokens is not considered as a vulnerability.
48// Do not report this to our security service.
49//
50// See:
51// - <https://github.com/CleverCloud/clever-tools/blob/fed085e2ba0339f55e966d7c8c6439d4dac71164/src/models/configuration.js#L128>
52pub const DEFAULT_CONSUMER_KEY: &str = "T5nFjKeHH4AIlEveuGhB5S3xg8T19e";
53pub fn default_consumer_key() -> String {
54    DEFAULT_CONSUMER_KEY.to_string()
55}
56
57pub const DEFAULT_CONSUMER_SECRET: &str = "MgVMqTr6fWlf2M0tkC2MXOnhfqBWDT";
58pub fn default_consumer_secret() -> String {
59    DEFAULT_CONSUMER_SECRET.to_string()
60}
61
62// -----------------------------------------------------------------------------
63// ResponseError / ClientError structures
64
65/// Body of an error response returned by the Clever Cloud API.
66pub type ResponseError = serde_json::Value;
67
68/// Error returned by the [`Client`] when executing a request. It keeps the
69/// historical shape (in particular the `StatusCode` variant) expected by the
70/// rest of the SDK and by downstream consumers.
71#[derive(thiserror::Error, Debug)]
72pub enum ClientError {
73    #[error("failed to execute request, {0}")]
74    Request(reqwest::Error),
75    #[error("failed to authorize or execute request, {0}")]
76    Execute(String),
77    #[error("failed to execute request, got status code {0}, {1}")]
78    StatusCode(StatusCode, ResponseError),
79    #[error("failed to serialize request body, {0}")]
80    Serialize(serde_json::Error),
81    #[error("failed to deserialize response body, {0}")]
82    Deserialize(serde_json::Error),
83}
84
85impl From<RestError<OAuthClientError>> for ClientError {
86    fn from(err: RestError<OAuthClientError>) -> Self {
87        match err {
88            RestError::Execute(err) => Self::Execute(err.to_string()),
89            RestError::Serialize(err) => Self::Serialize(err),
90            RestError::Deserialize(err) => Self::Deserialize(err),
91            RestError::Url(err) | RestError::BodyAggregation(err) => Self::Request(err),
92            RestError::TooManyHeaders(err) => Self::Execute(err.to_string()),
93        }
94    }
95}
96
97impl From<OAuthClientError> for ClientError {
98    fn from(err: OAuthClientError) -> Self {
99        Self::Execute(err.to_string())
100    }
101}
102
103// -----------------------------------------------------------------------------
104// Request / RestClient traits
105
106/// Low-level request execution, kept for the historical surface.
107pub trait Request {
108    type Error;
109
110    fn request<T, U>(
111        &self,
112        method: &Method,
113        endpoint: &str,
114        payload: &T,
115    ) -> impl Future<Output = Result<U, Self::Error>> + Send
116    where
117        T: ?Sized + Serialize + Debug + Send + Sync,
118        U: DeserializeOwned + Debug + Send + Sync;
119
120    fn execute(
121        &self,
122        request: reqwest::Request,
123    ) -> impl Future<Output = Result<reqwest::Response, Self::Error>> + Send;
124}
125
126/// `RESTful` helpers returning a flat `Result<T, Self::Error>`.
127pub trait RestClient {
128    type Error;
129
130    fn get<T>(&self, endpoint: &str) -> impl Future<Output = Result<T, Self::Error>> + Send
131    where
132        T: DeserializeOwned + Debug + Send + Sync;
133
134    fn post<T, U>(
135        &self,
136        endpoint: &str,
137        payload: &T,
138    ) -> impl Future<Output = Result<U, Self::Error>> + Send
139    where
140        T: ?Sized + Serialize + Debug + Send + Sync,
141        U: DeserializeOwned + Debug + Send + Sync;
142
143    fn put<T, U>(
144        &self,
145        endpoint: &str,
146        payload: &T,
147    ) -> impl Future<Output = Result<U, Self::Error>> + Send
148    where
149        T: ?Sized + Serialize + Debug + Send + Sync,
150        U: DeserializeOwned + Debug + Send + Sync;
151
152    fn patch<T, U>(
153        &self,
154        endpoint: &str,
155        payload: &T,
156    ) -> impl Future<Output = Result<U, Self::Error>> + Send
157    where
158        T: ?Sized + Serialize + Debug + Send + Sync,
159        U: DeserializeOwned + Debug + Send + Sync;
160
161    fn delete(&self, endpoint: &str) -> impl Future<Output = Result<(), Self::Error>> + Send;
162}
163
164// -----------------------------------------------------------------------------
165// Credentials structure
166
167#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
168#[serde(untagged)]
169pub enum Credentials {
170    OAuth1 {
171        #[serde(rename = "token")]
172        token: String,
173        #[serde(rename = "secret")]
174        secret: String,
175        #[serde(rename = "consumer-key", default = "default_consumer_key")]
176        consumer_key: String,
177        #[serde(rename = "consumer-secret", default = "default_consumer_secret")]
178        consumer_secret: String,
179    },
180    Basic {
181        #[serde(rename = "username")]
182        username: String,
183        #[serde(rename = "password")]
184        password: String,
185    },
186    Bearer {
187        #[serde(rename = "token")]
188        token: String,
189    },
190}
191
192impl Default for Credentials {
193    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
194    fn default() -> Self {
195        Self::OAuth1 {
196            token: String::new(),
197            secret: String::new(),
198            consumer_key: DEFAULT_CONSUMER_KEY.to_string(),
199            consumer_secret: DEFAULT_CONSUMER_SECRET.to_string(),
200        }
201    }
202}
203
204impl From<OAuthCredentials> for Credentials {
205    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
206    fn from(credentials: OAuthCredentials) -> Self {
207        match credentials {
208            OAuthCredentials::Bearer { token } => Self::Bearer {
209                token: token.into(),
210            },
211            OAuthCredentials::Basic { username, password } => Self::Basic {
212                username: username.into(),
213                password: password.map(Into::into).unwrap_or_default(),
214            },
215            OAuthCredentials::OAuth1 {
216                token,
217                secret,
218                consumer_key,
219                consumer_secret,
220            } => Self::OAuth1 {
221                token: token.into(),
222                secret: secret.into(),
223                consumer_key: consumer_key.into(),
224                consumer_secret: consumer_secret.into(),
225            },
226        }
227    }
228}
229
230impl From<Credentials> for OAuthCredentials {
231    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
232    fn from(credentials: Credentials) -> Self {
233        match credentials {
234            Credentials::Bearer { token } => Self::Bearer {
235                token: token.into(),
236            },
237            Credentials::Basic { username, password } => Self::Basic {
238                username: username.into(),
239                password: Some(password.into()),
240            },
241            Credentials::OAuth1 {
242                token,
243                secret,
244                consumer_key,
245                consumer_secret,
246            } => Self::OAuth1 {
247                token: token.into(),
248                secret: secret.into(),
249                consumer_key: consumer_key.into(),
250                consumer_secret: consumer_secret.into(),
251            },
252        }
253    }
254}
255
256impl Credentials {
257    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
258    pub fn bearer(token: String) -> Self {
259        Self::Bearer { token }
260    }
261
262    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
263    pub fn basic(username: String, password: String) -> Self {
264        Self::Basic { username, password }
265    }
266
267    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
268    pub fn oauth1(
269        token: String,
270        secret: String,
271        consumer_key: String,
272        consumer_secret: String,
273    ) -> Self {
274        Self::OAuth1 {
275            token,
276            secret,
277            consumer_key,
278            consumer_secret,
279        }
280    }
281}
282
283// -----------------------------------------------------------------------------
284// Builder structure
285
286#[derive(Clone, Debug, Default)]
287pub struct Builder {
288    endpoint: Option<String>,
289    credentials: Option<Credentials>,
290}
291
292impl Builder {
293    #[cfg_attr(feature = "tracing", tracing::instrument)]
294    pub fn with_endpoint(mut self, endpoint: String) -> Self {
295        self.endpoint = Some(endpoint);
296        self
297    }
298
299    #[cfg_attr(feature = "tracing", tracing::instrument)]
300    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
301        self.credentials = Some(credentials);
302        self
303    }
304
305    #[cfg_attr(feature = "tracing", tracing::instrument)]
306    pub fn build(self, client: reqwest::Client) -> Client {
307        let endpoint = match self.endpoint {
308            Some(endpoint) => endpoint,
309            None => {
310                if matches!(self.credentials, Some(Credentials::Bearer { .. })) {
311                    PUBLIC_API_BRIDGE_ENDPOINT.to_string()
312                } else {
313                    PUBLIC_ENDPOINT.to_string()
314                }
315            }
316        };
317
318        let inner = OAuthClient::from(client)
319            .with_credentials(self.credentials.map(Into::<OAuthCredentials>::into));
320
321        Client { inner, endpoint }
322    }
323}
324
325// -----------------------------------------------------------------------------
326// Client structure
327
328#[derive(Clone, Debug)]
329pub struct Client {
330    inner: OAuthClient,
331    endpoint: String,
332}
333
334impl Request for Client {
335    type Error = ClientError;
336
337    #[cfg_attr(feature = "tracing", tracing::instrument)]
338    fn request<T, U>(
339        &self,
340        method: &Method,
341        endpoint: &str,
342        payload: &T,
343    ) -> impl Future<Output = Result<U, Self::Error>> + Send
344    where
345        T: ?Sized + Serialize + Debug + Send + Sync,
346        U: DeserializeOwned + Debug + Send + Sync,
347    {
348        let fut =
349            OAuthRestClient::request::<T, U, ResponseError>(&self.inner, method, endpoint, payload);
350        async move { flatten(fut.await) }
351    }
352
353    #[cfg_attr(feature = "tracing", tracing::instrument(skip(request)))]
354    fn execute(
355        &self,
356        request: reqwest::Request,
357    ) -> impl Future<Output = Result<reqwest::Response, Self::Error>> + Send {
358        let fut = self.inner.execute_request(request);
359        async move { fut.await.map_err(ClientError::from) }
360    }
361}
362
363impl RestClient for Client {
364    type Error = ClientError;
365
366    #[cfg_attr(feature = "tracing", tracing::instrument)]
367    fn get<T>(&self, endpoint: &str) -> impl Future<Output = Result<T, Self::Error>> + Send
368    where
369        T: DeserializeOwned + Debug + Send + Sync,
370    {
371        let fut = OAuthRestClient::get::<T, ResponseError>(&self.inner, endpoint);
372        async move { flatten(fut.await) }
373    }
374
375    #[cfg_attr(feature = "tracing", tracing::instrument)]
376    fn post<T, U>(
377        &self,
378        endpoint: &str,
379        payload: &T,
380    ) -> impl Future<Output = Result<U, Self::Error>> + Send
381    where
382        T: ?Sized + Serialize + Debug + Send + Sync,
383        U: DeserializeOwned + Debug + Send + Sync,
384    {
385        let fut = OAuthRestClient::post::<T, U, ResponseError>(&self.inner, endpoint, payload);
386        async move { flatten(fut.await) }
387    }
388
389    #[cfg_attr(feature = "tracing", tracing::instrument)]
390    fn put<T, U>(
391        &self,
392        endpoint: &str,
393        payload: &T,
394    ) -> impl Future<Output = Result<U, Self::Error>> + Send
395    where
396        T: ?Sized + Serialize + Debug + Send + Sync,
397        U: DeserializeOwned + Debug + Send + Sync,
398    {
399        let fut = OAuthRestClient::put::<T, U, ResponseError>(&self.inner, endpoint, payload);
400        async move { flatten(fut.await) }
401    }
402
403    #[cfg_attr(feature = "tracing", tracing::instrument)]
404    fn patch<T, U>(
405        &self,
406        endpoint: &str,
407        payload: &T,
408    ) -> impl Future<Output = Result<U, Self::Error>> + Send
409    where
410        T: ?Sized + Serialize + Debug + Send + Sync,
411        U: DeserializeOwned + Debug + Send + Sync,
412    {
413        let fut = OAuthRestClient::patch::<T, U, ResponseError>(&self.inner, endpoint, payload);
414        async move { flatten(fut.await) }
415    }
416
417    #[cfg_attr(feature = "tracing", tracing::instrument)]
418    fn delete(&self, endpoint: &str) -> impl Future<Output = Result<(), Self::Error>> + Send {
419        let fut = OAuthRestClient::delete::<ResponseError>(&self.inner, endpoint);
420        async move { flatten(fut.await) }
421    }
422}
423
424/// Flattens the nested result returned by oauth10a 3.0.0's `RestClient` into the
425/// historical flat `Result<T, ClientError>`.
426fn flatten<T>(
427    result: Result<Result<T, ErrorResponse<ResponseError>>, RestError<OAuthClientError>>,
428) -> Result<T, ClientError> {
429    match result {
430        Ok(Ok(value)) => Ok(value),
431        Ok(Err(ErrorResponse { status_code, value })) => {
432            Err(ClientError::StatusCode(status_code, value))
433        }
434        Err(err) => Err(ClientError::from(err)),
435    }
436}
437
438impl From<reqwest::Client> for Client {
439    #[cfg_attr(feature = "tracing", tracing::instrument)]
440    fn from(client: reqwest::Client) -> Self {
441        Self::builder().build(client)
442    }
443}
444
445impl From<Credentials> for Client {
446    #[cfg_attr(feature = "tracing", tracing::instrument)]
447    fn from(credentials: Credentials) -> Self {
448        match &credentials {
449            Credentials::Bearer { .. } => Self::builder()
450                .with_endpoint(PUBLIC_API_BRIDGE_ENDPOINT.to_string())
451                .with_credentials(credentials)
452                .build(reqwest::Client::new()),
453            _ => Self::builder()
454                .with_credentials(credentials)
455                .build(reqwest::Client::new()),
456        }
457    }
458}
459
460impl Default for Client {
461    #[cfg_attr(feature = "tracing", tracing::instrument)]
462    fn default() -> Self {
463        Self::builder().build(reqwest::Client::new())
464    }
465}
466
467impl Client {
468    #[cfg_attr(feature = "tracing", tracing::instrument)]
469    pub fn new(
470        client: reqwest::Client,
471        endpoint: String,
472        credentials: Option<Credentials>,
473    ) -> Self {
474        let mut builder = Self::builder().with_endpoint(endpoint);
475
476        if let Some(credentials) = credentials {
477            builder = builder.with_credentials(credentials);
478        }
479
480        builder.build(client)
481    }
482
483    #[cfg_attr(feature = "tracing", tracing::instrument)]
484    pub fn builder() -> Builder {
485        Builder::default()
486    }
487
488    #[cfg_attr(feature = "tracing", tracing::instrument)]
489    pub fn set_endpoint(&mut self, endpoint: String) {
490        self.endpoint = endpoint;
491    }
492
493    #[cfg_attr(feature = "tracing", tracing::instrument)]
494    pub fn set_credentials(&mut self, credentials: Option<Credentials>) {
495        self.inner
496            .set_credentials(credentials.map(Into::<OAuthCredentials>::into));
497    }
498
499    #[cfg_attr(feature = "tracing", tracing::instrument)]
500    pub fn inner(&self) -> &reqwest::Client {
501        self.inner.inner()
502    }
503}
504
505// -----------------------------------------------------------------------------
506// Tests
507
508#[cfg(test)]
509mod tests {
510    use serde_json::json;
511
512    use super::*;
513
514    /// A non-success status returned by the API must surface as
515    /// `ClientError::StatusCode`, carrying both the status and the response body.
516    ///
517    /// This is the contract the operator relies on to tell "addon not found"
518    /// (404) apart from a transport failure and trigger a recreate; if `flatten`
519    /// ever routed a 404 through the `RestError` arm it would become
520    /// `ClientError::Execute` and that reconciliation logic would silently break.
521    #[test]
522    fn flatten_maps_error_response_to_status_code() {
523        let body = json!({ "id": 4004, "message": "addon not found" });
524        let result: Result<Result<(), ErrorResponse<ResponseError>>, RestError<OAuthClientError>> =
525            Ok(Err(ErrorResponse {
526                status_code: StatusCode::NOT_FOUND,
527                value: body.clone(),
528            }));
529
530        match flatten(result) {
531            Err(ClientError::StatusCode(status, value)) => {
532                assert_eq!(status, StatusCode::NOT_FOUND);
533                assert_eq!(value, body);
534            }
535            other => panic!("expected ClientError::StatusCode(404, _), got {other:?}"),
536        }
537    }
538
539    /// A successful response is forwarded untouched.
540    #[test]
541    fn flatten_forwards_success() {
542        let result: Result<Result<u32, ErrorResponse<ResponseError>>, RestError<OAuthClientError>> =
543            Ok(Ok(42));
544
545        assert!(matches!(flatten(result), Ok(42)));
546    }
547
548    /// Transport / (de)serialization failures travel through the `RestError` arm
549    /// and must keep their own variant rather than being misreported as a status
550    /// code.
551    #[test]
552    fn flatten_keeps_transport_errors_distinct_from_status_code() {
553        let serde_err = serde_json::from_str::<u32>("not-a-number").unwrap_err();
554        let result: Result<Result<(), ErrorResponse<ResponseError>>, RestError<OAuthClientError>> =
555            Err(RestError::Serialize(serde_err));
556
557        assert!(matches!(flatten(result), Err(ClientError::Serialize(_))));
558    }
559}