office_convert_client/
client.rs

1use bytes::Bytes;
2use reqwest::multipart::{Form, Part};
3use serde::Deserialize;
4use std::{sync::Arc, time::Duration};
5use thiserror::Error;
6
7#[derive(Clone)]
8pub struct OfficeConvertClient {
9    /// HTTP client to connect to the server with
10    http: reqwest::Client,
11    /// Host the office convert server is running on
12    host: Arc<str>,
13}
14
15/// Errors that can occur during setup
16#[derive(Debug, Error)]
17pub enum CreateError {
18    /// Builder failed to create HTTP client
19    #[error(transparent)]
20    Builder(reqwest::Error),
21}
22
23/// Errors that can occur during a request
24#[derive(Debug, Error)]
25pub enum RequestError {
26    /// Failed to request the server
27    #[error(transparent)]
28    RequestFailed(reqwest::Error),
29
30    /// Response from the server was invalid
31    #[error(transparent)]
32    InvalidResponse(reqwest::Error),
33
34    /// Reached timeout when trying to connect
35    #[error("server connection timed out")]
36    ServerConnectTimeout,
37
38    /// Error message from the convert server reply
39    #[error("{reason}")]
40    ErrorResponse {
41        reason: String,
42        backtrace: Option<String>,
43    },
44}
45
46impl RequestError {
47    // Whether a retry attempt should be made
48    pub fn is_retry(&self) -> bool {
49        matches!(
50            self,
51            RequestError::RequestFailed(_)
52                | RequestError::InvalidResponse(_)
53                | RequestError::ServerConnectTimeout
54        )
55    }
56}
57
58#[derive(Debug, Deserialize)]
59pub struct StatusResponse {
60    pub is_busy: bool,
61}
62
63#[derive(Debug, Deserialize)]
64pub struct SupportedFormat {
65    /// Name of the file format
66    pub name: String,
67    /// Mime type of the format
68    pub mime: String,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct VersionResponse {
73    /// Major version of LibreOffice
74    pub major: u32,
75    /// Minor version of LibreOffice
76    pub minor: u32,
77    /// Libreoffice "Build ID"
78    pub build_id: String,
79}
80
81#[derive(Debug, Deserialize)]
82#[serde(rename_all = "camelCase")]
83struct ErrorResponse {
84    /// Server reason for the error
85    reason: String,
86    /// Server backtrace if available
87    backtrace: Option<String>,
88}
89
90#[derive(Debug, Clone)]
91pub struct ClientOptions {
92    /// Connection timeout used when checking the status of the server
93    pub connect_timeout: Option<Duration>,
94
95    /// Timeout when reading responses from the server
96    pub read_timeout: Option<Duration>,
97}
98
99impl Default for ClientOptions {
100    fn default() -> Self {
101        Self {
102            // Allow the connection to fail if not established in 700ms
103            connect_timeout: Some(Duration::from_millis(700)),
104            read_timeout: None,
105        }
106    }
107}
108
109impl OfficeConvertClient {
110    /// Creates a new office convert client using the default options
111    ///
112    /// ## Arguments
113    /// * `host` - The host where the server is located
114    pub fn new<T>(host: T) -> Result<Self, CreateError>
115    where
116        T: Into<Arc<str>>,
117    {
118        Self::new_with_options(host, ClientOptions::default())
119    }
120
121    /// Creates a new office convert client using the provided options
122    ///
123    /// ## Arguments
124    /// * `host` - The host where the server is located
125    /// * `options` - The configuration options for the client
126    pub fn new_with_options<T>(host: T, options: ClientOptions) -> Result<Self, CreateError>
127    where
128        T: Into<Arc<str>>,
129    {
130        let mut builder = reqwest::Client::builder();
131
132        if let Some(connect_timeout) = options.connect_timeout {
133            builder = builder.connect_timeout(connect_timeout);
134        }
135
136        if let Some(connect_timeout) = options.read_timeout {
137            builder = builder.read_timeout(connect_timeout);
138        }
139
140        let client = builder.build().map_err(CreateError::Builder)?;
141        Ok(Self::from_client(host, client))
142    }
143
144    /// Create an office convert client from an existing [reqwest::Client] if
145    /// your setup is more advanced than the default configuration
146    ///
147    /// ## Arguments
148    /// * `host` - The host where the server is located
149    /// * `client` - The request HTTP client to use
150    pub fn from_client<T>(host: T, client: reqwest::Client) -> Self
151    where
152        T: Into<Arc<str>>,
153    {
154        Self {
155            http: client,
156            host: host.into(),
157        }
158    }
159
160    /// Obtains the current status of the converter server
161    pub async fn get_status(&self) -> Result<StatusResponse, RequestError> {
162        let route = format!("{}/status", self.host);
163        let response = self
164            .http
165            .get(route)
166            .send()
167            .await
168            .map_err(RequestError::RequestFailed)?;
169
170        let status = response.status();
171
172        // Handle error responses
173        if status.is_client_error() || status.is_server_error() {
174            let body: ErrorResponse = response
175                .json()
176                .await
177                .map_err(RequestError::InvalidResponse)?;
178
179            return Err(RequestError::ErrorResponse {
180                reason: body.reason,
181                backtrace: body.backtrace,
182            });
183        }
184
185        // Extract the response message
186        let response: StatusResponse = response
187            .json()
188            .await
189            .map_err(RequestError::InvalidResponse)?;
190
191        Ok(response)
192    }
193
194    /// Obtains the LibreOffice version that the server is using
195    pub async fn get_office_version(&self) -> Result<VersionResponse, RequestError> {
196        let route = format!("{}/office-version", self.host);
197        let response = self
198            .http
199            .get(route)
200            .send()
201            .await
202            .map_err(RequestError::RequestFailed)?;
203
204        let status = response.status();
205
206        // Handle error responses
207        if status.is_client_error() || status.is_server_error() {
208            let body: ErrorResponse = response
209                .json()
210                .await
211                .map_err(RequestError::InvalidResponse)?;
212
213            return Err(RequestError::ErrorResponse {
214                reason: body.reason,
215                backtrace: body.backtrace,
216            });
217        }
218
219        // Extract the response message
220        let response: VersionResponse = response
221            .json()
222            .await
223            .map_err(RequestError::InvalidResponse)?;
224
225        Ok(response)
226    }
227
228    /// Obtains the list of supported file formats from the server, will give back
229    /// an error if the version of LibreOffice does not support querying the
230    /// available file types
231    pub async fn get_supported_formats(&self) -> Result<Vec<SupportedFormat>, RequestError> {
232        let route = format!("{}/supported-formats", self.host);
233        let response = self
234            .http
235            .get(route)
236            .send()
237            .await
238            .map_err(RequestError::RequestFailed)?;
239
240        let status = response.status();
241
242        // Handle error responses
243        if status.is_client_error() || status.is_server_error() {
244            let body: ErrorResponse = response
245                .json()
246                .await
247                .map_err(RequestError::InvalidResponse)?;
248
249            return Err(RequestError::ErrorResponse {
250                reason: body.reason,
251                backtrace: body.backtrace,
252            });
253        }
254
255        // Extract the response message
256        let response: Vec<SupportedFormat> = response
257            .json()
258            .await
259            .map_err(RequestError::InvalidResponse)?;
260
261        Ok(response)
262    }
263
264    /// Gets the current busy status of the convert server
265    pub async fn is_busy(&self) -> Result<bool, RequestError> {
266        let status = self.get_status().await?;
267        Ok(status.is_busy)
268    }
269
270    /// Tells the converter server to collect garbage
271    pub async fn collect_garbage(&self) -> Result<(), RequestError> {
272        let route = format!("{}/collect-garbage", self.host);
273        let response = self
274            .http
275            .post(route)
276            .send()
277            .await
278            .map_err(RequestError::RequestFailed)?;
279
280        let status = response.status();
281
282        // Handle error responses
283        if status.is_client_error() || status.is_server_error() {
284            let body: ErrorResponse = response
285                .json()
286                .await
287                .map_err(RequestError::InvalidResponse)?;
288
289            return Err(RequestError::ErrorResponse {
290                reason: body.reason,
291                backtrace: body.backtrace,
292            });
293        }
294
295        Ok(())
296    }
297
298    pub async fn convert(&self, file: Bytes) -> Result<Bytes, RequestError> {
299        let route = format!("{}/convert", self.host);
300        let form = Form::new().part("file", Part::stream(file));
301        let response = self
302            .http
303            .post(route)
304            .multipart(form)
305            .send()
306            .await
307            .map_err(RequestError::RequestFailed)?;
308
309        let status = response.status();
310
311        // Handle error responses
312        if status.is_client_error() || status.is_server_error() {
313            let body: ErrorResponse = response
314                .json()
315                .await
316                .map_err(RequestError::InvalidResponse)?;
317
318            return Err(RequestError::ErrorResponse {
319                reason: body.reason,
320                backtrace: body.backtrace,
321            });
322        }
323
324        let response = response
325            .bytes()
326            .await
327            .map_err(RequestError::InvalidResponse)?;
328
329        Ok(response)
330    }
331}