fundamentum_sdk_api/client/
sdk_client.rs

1//! Client wrapping the [`reqwest::Client`] to make request with Fundamentum via normal functions
2
3use super::{config::ClientConfig, errors::SdkClientError};
4use crate::models::{
5    api_response::{ApiResponse, ApiStatus},
6    constants::{OPEN_API_DOWNLOAD_PATH, STATUS_PATH},
7    general::Status,
8};
9use reqwest::{Client, Method, StatusCode};
10use serde::{de::DeserializeOwned, Serialize};
11use std::path::PathBuf;
12use url::Url;
13
14/// Wrapper of a [`reqwest::Client`] to make API requests to Fundamentum.
15/// It contains the possible HTTP methods to be used with the client.
16#[derive(Clone)]
17pub struct SdkClient {
18    inner_client: Client,
19    config: ClientConfig,
20    api_version: String,
21}
22
23impl SdkClient {
24    /// Create a new Client.
25    #[must_use]
26    pub fn new(config: ClientConfig, api_version: &str) -> Self {
27        Self {
28            inner_client: Client::new(),
29            config,
30            api_version: api_version.to_owned(),
31        }
32    }
33
34    /// Get the current configuration of this client.
35    #[must_use]
36    pub const fn config(&self) -> &ClientConfig {
37        &self.config
38    }
39
40    /// Retrieve the API's status.
41    ///
42    /// # Errors
43    ///
44    /// There may be an error to get the HTTP Response, to interpret its Json
45    /// body into the [`Response`](crate::models::api_response::ApiResponse).
46    pub async fn status(&self) -> Result<Status, SdkClientError> {
47        let url = Url::parse(&format!("{}{}", self.config().base_path, STATUS_PATH))
48            .map_err(SdkClientError::BasePathError)?;
49
50        let response = self
51            .inner_client
52            .request(Method::GET, url)
53            .send()
54            .await
55            .map_err(SdkClientError::ReqwestError)?;
56        let status_code = response.status();
57
58        Self::extract_response(
59            response
60                .json()
61                .await
62                .map_err(|e| SdkClientError::BodyToJsonError(e, status_code))?,
63            status_code,
64        )
65    }
66
67    /// Retrieve the Open API Json file.
68    ///
69    /// # Errors
70    ///
71    /// There may be an error to get the HTTP Response, to interpret its Json
72    /// body into the [`Response`](crate::models::api_response::ApiResponse).
73    pub async fn get_openapi_json(&self) -> Result<serde_json::Value, SdkClientError> {
74        let url = Url::parse(&format!(
75            "{}{}",
76            self.config().base_path,
77            OPEN_API_DOWNLOAD_PATH
78        ))
79        .map_err(SdkClientError::BasePathError)?;
80
81        let response = self
82            .inner_client
83            .request(Method::GET, url)
84            .send()
85            .await
86            .map_err(SdkClientError::ReqwestError)?;
87        let status_code = response.status();
88
89        response
90            .json::<serde_json::Value>()
91            .await
92            .map_err(|e| SdkClientError::BodyToJsonError(e, status_code))
93    }
94
95    /// Method to perform a GET HTTP request.
96    ///
97    /// # Errors
98    ///
99    /// The request may fail if the path is invalid or inexistant.
100    pub async fn get<T, S>(&self, path: S) -> Result<T, SdkClientError>
101    where
102        T: DeserializeOwned,
103        S: Into<String> + Send,
104    {
105        self.send(Method::GET, path, Option::<&str>::None).await
106    }
107
108    /// Method to perform a DELETE HTTP request.
109    ///
110    /// # Errors
111    ///
112    /// The request may fail if the path is invalid or inexistant.
113    pub async fn delete<T, S>(&self, path: S) -> Result<T, SdkClientError>
114    where
115        T: DeserializeOwned,
116        S: Into<String> + Send,
117    {
118        self.send(Method::DELETE, path, Option::<&str>::None).await
119    }
120
121    /// Method to perform a POST HTTP request with a body.
122    ///
123    /// # Errors
124    ///
125    /// The request may fail if the path is invalid or inexistant, or if the body is invalid.
126    pub async fn post_body<T, S, B>(&self, path: S, body: B) -> Result<T, SdkClientError>
127    where
128        T: DeserializeOwned,
129        S: Into<String> + Send,
130        B: Serialize + Send + Sync,
131    {
132        self.send(Method::POST, path, Some(body)).await
133    }
134
135    /// Method to perform a PUT HTTP request with a body.
136    ///
137    /// # Errors
138    ///
139    /// The request may fail if the path is invalid or inexistant, or if the body is invalid.
140    pub async fn put_body<T, S, B>(&self, path: S, body: B) -> Result<T, SdkClientError>
141    where
142        T: DeserializeOwned,
143        S: Into<String> + Send,
144        B: Serialize + Send + Sync,
145    {
146        self.send(Method::PUT, path, Some(body)).await
147    }
148
149    /// Method that actually performs the generic HTTP request.
150    ///
151    /// # Errors
152    ///
153    /// The request may fail if the path is invalid or inexistant, or if the body is invalid.
154    async fn send<T, S, B>(
155        &self,
156        method: Method,
157        path: S,
158        maybe_body: Option<B>,
159    ) -> Result<T, SdkClientError>
160    where
161        T: DeserializeOwned,
162        S: Into<String> + Send,
163        B: Serialize + Send + Sync,
164    {
165        let url = self.make_url(&path.into())?;
166
167        let request = self.inner_client.request(method, url);
168        let request = match self.config().api_token {
169            Some(ref api_token) => request.header("Authorization", format!("Bearer {api_token}")),
170            _ => request,
171        };
172
173        // Performing the request
174        let response = match maybe_body {
175            Some(body) => request
176                .json(&body)
177                .send()
178                .await
179                .map_err(SdkClientError::ReqwestError)?,
180            None => request.send().await.map_err(SdkClientError::ReqwestError)?,
181        };
182        let status_code = response.status();
183
184        // Extracting the response
185        Self::extract_response(
186            response
187                .json()
188                .await
189                .map_err(|e| SdkClientError::BodyToJsonError(e, status_code))?,
190            status_code,
191        )
192    }
193
194    fn make_url(&self, end_path: &str) -> Result<Url, SdkClientError> {
195        let buffer = PathBuf::from("/api/")
196            .join(&self.api_version)
197            .join(end_path.strip_prefix('/').unwrap_or(end_path));
198
199        let url_string = buffer
200            .to_str()
201            .ok_or(SdkClientError::JoinPathError(buffer.clone()))?
202            .to_owned();
203
204        Url::parse(&format!("{}{}", self.config().base_path, url_string))
205            .map_err(SdkClientError::BasePathError)
206    }
207
208    fn extract_response<T: DeserializeOwned>(
209        api_response: ApiResponse,
210        status_code: StatusCode,
211    ) -> Result<T, SdkClientError> {
212        match api_response.status {
213            Some(ApiStatus::Success) => serde_json::from_value::<T>(api_response.data.into())
214                .map_err(SdkClientError::DataToJsonError),
215
216            Some(ApiStatus::Error) => api_response.clone().message.map_or_else(
217                || {
218                    Err(SdkClientError::MissingMessageField(
219                        api_response.clone(),
220                        status_code,
221                    ))
222                },
223                |message| {
224                    Err(SdkClientError::GenericApiError(
225                        message,
226                        api_response.clone(),
227                        status_code,
228                    ))
229                },
230            ),
231
232            None => Err(SdkClientError::MissingStatusField(
233                api_response,
234                status_code,
235            )),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::models::constants::PROVISIONING_PATH;
244
245    #[tokio::test]
246    async fn when_status_request_then_returns_good_status() {
247        let client = SdkClient::new(
248            ClientConfig::builder("https://devices.fundamentum-iot-dev.com".to_owned()).build(),
249            "v3",
250        );
251
252        let status = client.status().await;
253
254        assert!(status.is_ok(), "{status:?}");
255    }
256
257    #[tokio::test]
258    async fn when_getting_openapi_file_then_returns_json_file() {
259        let client = SdkClient::new(
260            ClientConfig::builder("https://devices.fundamentum-iot-dev.com".to_owned()).build(),
261            "v3",
262        );
263
264        let json_file = client.get_openapi_json().await;
265
266        assert!(json_file.is_ok(), "{json_file:?}");
267    }
268
269    #[test]
270    fn given_valid_path_when_make_url_then_returns_good_url() {
271        let client = SdkClient::new(
272            ClientConfig::builder("https://devices.fundamentum-iot-dev.com".to_owned()).build(),
273            "v3",
274        );
275        let path = PROVISIONING_PATH;
276        let valid_url = Url::parse(&format!(
277            "{}/api/{}{}",
278            client.config().base_path,
279            client.api_version,
280            path
281        ))
282        .expect("valid_url must be valid");
283
284        let result = client.make_url(path);
285
286        assert!(result.is_ok(), "{result:?}");
287        let url = result.expect("Must be a valid url");
288        assert_eq!(url, valid_url);
289    }
290}