uploads_im_client/
lib.rs

1#![deny(missing_docs)]
2//! # Uploads.im Client
3//!
4//! This crate is a thin wrapper that models the Uploads.im web API. Currently,
5//! the only functionality available to Uploads.im users is the `upload`
6//! endpoint.
7//!
8//! NOTE: At the time of writing, the Uploads.im service is not accepting new
9//! uploads. You will most likely need to find an alternate provider right now.
10//!
11//! # Examples
12//!
13//! ```rust,no_run
14//! use {reqwest::Client, uploads_im_client::upload_with_default_options};
15//!
16//! #[tokio::main]
17//! async fn main() {
18//!     let uploaded_image = upload_with_default_options(
19//!         &mut Client::new(),
20//!         "my_image.jpg".to_owned().into(),
21//!     ) .await.expect("successful image upload");
22//!     println!("Uploaded image! You can now view it at {}", uploaded_image.view_url.as_str());
23//! }
24//! ```
25
26use {
27    derive_builder::Builder,
28    log::{debug, info, trace},
29    reqwest::{multipart::{Form, Part}, Client, StatusCode},
30    serde::{de::{Error as DeserializationError, Unexpected}, Deserialize, Deserializer},
31    std::{convert::TryFrom, path::PathBuf},
32    thiserror::Error,
33    url::Url,
34};
35
36/// The default host that the Uploads.im service uses in production.
37pub const DEFAULT_HOST: &str = "uploads.im";
38
39/// The integral type that thumbnail image dimensions use.
40pub type ThumbnailDimension = u32;
41/// The integral type that full image dimensions use.
42pub type FullSizeDimension = u64;
43
44/// Models options exposed to users of the upload API.
45#[derive(Builder, Clone, Debug)]
46pub struct UploadOptions {
47    /// The domain hosting the Uploads.im service
48    pub host: String,
49    /// An optional width to which an uploaded image should be resized to.
50    pub resize_width: Option<FullSizeDimension>,
51    /// An optional width to which the thumbnail of an uploaded image should be
52    /// resized to.
53    pub thumbnail_width: Option<ThumbnailDimension>,
54    /// An optional flag to mark an uploaded image as "family unsafe", or in
55    /// other words, adult content or NSFW.
56    pub family_unsafe: Option<bool>,
57}
58
59impl Default for UploadOptions {
60    fn default() -> Self {
61        Self {
62            host: DEFAULT_HOST.to_owned(),
63            resize_width: None,
64            thumbnail_width: None,
65            family_unsafe: None,
66        }
67    }
68}
69
70/// An abstract struct that encapsulates an image entry on the Uploads.im site.
71#[derive(Debug, Clone)]
72pub struct ImageReference<Dimension> {
73    /// The dimensions of the referred image.
74    pub dimensions: Rectangle<Dimension>,
75    /// The URL through which the referred image can be requested.
76    pub url: Url,
77}
78
79/// Represents a completed image upload to Uploads.im.
80#[derive(Debug, Clone)]
81pub struct UploadedImage {
82    /// The name of an uploaded image. This usually does **not** match the name
83    /// of the original uploaded image file. This name is usually an ID value,
84    /// followed by the original extension of the uploaded image. For example,
85    /// `something.jpg` may be renamed to `vwk7b.jpg`.
86    pub name: String,
87    /// A reference to the full-size uploaded image.
88    pub full_size: ImageReference<FullSizeDimension>,
89    /// A URL to a human-friendly page showing the uploaded image.
90    pub view_url: Url,
91    /// A reference to a thumbnail version of the uploaded image.
92    pub thumbnail: ImageReference<ThumbnailDimension>,
93    /// Flags whether or not the uploaded image was resized upon upload.
94    pub was_resized: bool,
95}
96
97/// An abstract struct that represents a rectangular area.
98#[derive(Debug, Clone)]
99pub struct Rectangle<T> {
100    /// The height of the rectangle
101    height: T,
102    /// The width of the rectangle
103    width: T,
104}
105
106/// Represents the possible responses given the upload API.
107#[derive(Debug, Clone, Deserialize)]
108#[serde(untagged)]
109enum RawUploadResponse {
110    /// Represents a upload failure
111    Failure {
112        #[serde(deserialize_with = "parse_status_code_string")]
113        status_code: StatusCode,
114        status_txt: String,
115    },
116    /// Represents an upload success
117    Success {
118        /// The data given in response to a successful image upload.
119        data: Box<RawUploadResponseSuccess>,
120    },
121}
122
123/// Deserializes an integral number string into an HTTP status code.
124fn parse_status_code_string<'de, D: serde::Deserializer<'de>>(
125    deserializer: D,
126) -> Result<StatusCode, D::Error> {
127    let status_code_number = u16::deserialize(deserializer)?;
128    StatusCode::from_u16(status_code_number).map_err(|_| {
129        D::Error::invalid_value(
130            Unexpected::Unsigned(u64::from(status_code_number)),
131            &"valid HTTP status code",
132        )
133    })
134}
135
136/// Represents a success response for an image uploaded using the upload API.
137#[derive(Debug, Clone, Deserialize)]
138struct RawUploadResponseSuccess {
139    img_name: String,
140    img_url: Url,
141    img_view: Url,
142    #[serde(deserialize_with = "parse_u64_string")]
143    img_height: FullSizeDimension,
144    #[serde(deserialize_with = "parse_u64_string")]
145    img_width: FullSizeDimension,
146    thumb_url: Url,
147    thumb_height: ThumbnailDimension,
148    thumb_width: ThumbnailDimension,
149    #[serde(deserialize_with = "parse_bool_number_string")]
150    resized: bool,
151}
152
153/// Deserializes an integral string into a `u64`.
154fn parse_u64_string<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
155    use std::error::Error as StdError;
156    use std::num::ParseIntError;
157
158    let string_value = String::deserialize(deserializer)?;
159    Ok(string_value.parse().map_err(|e: ParseIntError| {
160        let unexpected = Unexpected::Str(&string_value);
161        D::Error::invalid_value(unexpected, &e.description())
162    }))?
163}
164
165/// Deserializes an integral string into a `bool`.
166fn parse_bool_number_string<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
167    let parsed_number = parse_u64_string(deserializer)?;
168    Ok(match parsed_number {
169        0 => false,
170        1 => true,
171        _ => {
172            let unexpected = Unexpected::Unsigned(parsed_number);
173            Err(D::Error::invalid_value(
174                unexpected,
175                &"boolean integral value",
176            ))?
177        }
178    })
179}
180
181impl TryFrom<RawUploadResponse> for UploadedImage {
182    type Error = UploadError;
183    fn try_from(response: RawUploadResponse) -> Result<Self, Self::Error> {
184        match response {
185            RawUploadResponse::Failure {
186                status_code,
187                status_txt,
188            } => Err(UploadError::ResponseReturnedFailure {
189                status_code,
190                status_text: status_txt,
191            }),
192            RawUploadResponse::Success { data } => {
193                let d = *data;
194                let RawUploadResponseSuccess {
195                    img_name,
196                    img_url,
197                    img_view,
198                    img_height,
199                    img_width,
200                    thumb_url,
201                    thumb_height,
202                    thumb_width,
203                    resized,
204                } = d;
205
206                Ok(UploadedImage {
207                    name: img_name,
208                    full_size: ImageReference {
209                        url: img_url,
210                        dimensions: Rectangle {
211                            height: img_height,
212                            width: img_width,
213                        },
214                    },
215                    thumbnail: ImageReference {
216                        url: thumb_url,
217                        dimensions: Rectangle {
218                            height: thumb_height,
219                            width: thumb_width,
220                        },
221                    },
222                    view_url: img_view,
223                    was_resized: resized,
224                })
225            }
226        }
227    }
228}
229
230/// Represents an error that can occur when building an upload API URL.
231#[derive(Debug, Error)]
232pub enum UploadRequestURLBuildError {
233    /// Indicates that the upload URL could not be built.
234    #[error("URL params serialization failed")]
235    URLParamsBuildingFailed(#[source] serde_urlencoded::ser::Error),
236    /// Indicates that the built URL failed validation.
237    #[error("URL validation failed")]
238    URLValidationFailed(#[source] url::ParseError),
239}
240
241impl From<url::ParseError> for UploadRequestURLBuildError {
242    fn from(e: url::ParseError) -> Self {
243        UploadRequestURLBuildError::URLValidationFailed(e)
244    }
245}
246
247impl From<serde_urlencoded::ser::Error> for UploadRequestURLBuildError {
248    fn from(e: serde_urlencoded::ser::Error) -> Self {
249        UploadRequestURLBuildError::URLParamsBuildingFailed(e)
250    }
251}
252
253/// Represents an error that may occur when building and sending an image
254/// upload request.
255#[derive(Debug, Error)]
256pub enum UploadError {
257    /// Indicates a failure building an upload endpoint URL.
258    #[error("failed building upload request")]
259    BuildingRequest(
260        #[from]
261        #[source]
262        UploadRequestURLBuildError
263    ),
264    /// Indicates that the provided filename was invalid.
265    #[error("invalid filename \"{}\"", _0.display())]
266    InvalidFilename(PathBuf),
267    /// Indicates a upload request transmission error.
268    #[error("could not transmit upload request")]
269    SendingRequest(
270        #[from]
271        #[source]
272        reqwest::Error
273    ),
274    /// Indicates an error response returned by the upload API.
275    #[error(
276        "the server returned HTTP error code {} (\"{}\")",
277        status_code, status_text
278    )]
279    ResponseReturnedFailure {
280        /// The status code returned by the server. Note that this code is
281        /// contained in the *body* of the response, and not the header.
282        status_code: StatusCode,
283        /// A string describing the error returned by the API.
284        status_text: String,
285    },
286    /// Indicates an error accessing a file for upload.
287    #[error("cannot access file to upload")]
288    Io(
289        #[from]
290        #[source]
291        std::io::Error
292    ),
293    /// Indicates an error parsing the response from the upload API.
294    #[error("internal error: unable to parse upload response")]
295    ParsingResponse(
296        #[from]
297        #[source]
298        serde_json::Error
299    ),
300}
301
302/// Builds an upload endpoint URL given some `UploadOptions` suitable for a
303/// multipart form upload to Uploads.im.
304pub fn build_upload_url(options: &UploadOptions) -> Result<Url, UploadRequestURLBuildError> {
305    let url_string = {
306        let params = {
307            let &UploadOptions {
308                ref resize_width,
309                ref family_unsafe,
310                ..
311            } = options;
312
313            macro_rules! generate_string_keyed_pairs {
314                ($($arg: tt),*) => { [$(generate_string_keyed_pairs!(@inside $arg)),*] };
315                (@inside $e: ident) => { (stringify!($e), $e.map(|x| x.to_string())) };
316                (@inside $e: expr) => { $e };
317            }
318
319            let params_tuple = generate_string_keyed_pairs![
320                resize_width,
321                family_unsafe,
322                (
323                    "thumb_width",
324                    options.thumbnail_width.map(|x| x.to_string())
325                )
326            ];
327
328            serde_urlencoded::to_string(params_tuple)?
329        };
330        let initial_params_separator = if params.is_empty() { "" } else { "&" };
331
332        format!(
333            "http://{}/api?upload{}{}",
334            options.host, initial_params_separator, params
335        )
336    };
337
338    Ok(Url::parse(&url_string)?)
339}
340
341/// Uploads an image file denoted by `file_path` using the given `options` to
342/// the Uploads.im image upload API.
343pub async fn upload(
344    client: &mut Client,
345    file_path: PathBuf,
346    options: &UploadOptions,
347) -> Result<UploadedImage, UploadError> {
348    info!(
349        "Beginning upload of file \"{}\" with {:#?}",
350        file_path.display(),
351        options
352    );
353
354    let file_name = file_path
355        .file_name()
356        .and_then(|n| n.to_str().map(ToOwned::to_owned))
357        .ok_or_else(|| UploadError::InvalidFilename(file_path))?;
358
359    let endpoint_url = build_upload_url(options)?;
360
361    debug!("Upload URL: {}", endpoint_url.as_str());
362    let form = Form::new().part("fileupload", Part::stream("asdf").file_name(file_name));
363
364    trace!("Request built, sending now...");
365
366    let response = client
367        .post(endpoint_url.as_str())
368        .multipart(form)
369        .send().await?;
370
371    debug!("Got upload response: {:#?}", response);
372
373    let response_body_text = response.text().await?;
374
375    debug!("Upload response data: {:#?}", response_body_text);
376
377    let raw_upload_response: RawUploadResponse = serde_json::from_str(&response_body_text)?;
378
379    debug!("Parsed response: {:#?}", raw_upload_response);
380
381    let uploaded_image = UploadedImage::try_from(raw_upload_response)?;
382
383    Ok(uploaded_image)
384}
385
386/// Uploads an image file denoted by `file_path` using default `options` to
387/// the Uploads.im image upload API.
388pub async fn upload_with_default_options(
389    client: &mut Client,
390    file_path: PathBuf,
391) -> Result<UploadedImage, UploadError> {
392    upload(client, file_path, &UploadOptions::default()).await
393}