modio/util/download/
mod.rs

1//! Download interface for mod files.
2
3use std::fmt;
4use std::marker::PhantomData;
5use std::path::Path;
6
7use bytes::Bytes;
8use futures_util::StreamExt;
9use http::HeaderMap;
10use http_body_util::{BodyDataStream, BodyExt};
11use tokio::fs::File;
12use tokio::io::{AsyncWriteExt, BufWriter};
13
14mod action;
15mod error;
16mod info;
17
18pub use action::{DownloadAction, ResolvePolicy};
19pub use error::{Error, ErrorKind};
20pub use info::Info;
21
22use crate::client::service::{Body, Response};
23use crate::request::body::Body as RequestBody;
24use crate::Client;
25
26/// Extention trait for downloading mod files.
27pub trait Download: private::Sealed {
28    /// Returns [`Downloader`] for saving to file or retrieving the data chunked as `Bytes`.
29    ///
30    /// The download fails with [`modio::util::download::Error`] as source if a primary file, a
31    /// specific file or a specific version is not found.
32    ///
33    /// [`Downloader`]: crate::util::download::Downloader
34    /// [`modio::util::download::Error`]: crate::util::download::Error
35    ///
36    /// # Example
37    ///
38    /// ```no_run
39    /// use modio::types::id::Id;
40    /// use modio::util::download::{Download, DownloadAction, ResolvePolicy};
41    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
42    /// #    let modio = modio::Client::builder("user-or-game-api-key".to_owned()).build()?;
43    ///
44    /// // Download the primary file of a mod.
45    /// let action = DownloadAction::Primary {
46    ///     game_id: Id::new(5),
47    ///     mod_id: Id::new(19),
48    /// };
49    /// modio.download(action).save_to_file("mod.zip").await?;
50    ///
51    /// // Download the specific file of a mod.
52    /// let action = DownloadAction::File {
53    ///     game_id: Id::new(5),
54    ///     mod_id: Id::new(19),
55    ///     file_id: Id::new(101),
56    /// };
57    /// modio.download(action).save_to_file("mod.zip").await?;
58    ///
59    /// // Download the specific version of a mod.
60    /// // if multiple files are found then the latest file is downloaded.
61    /// // Set policy to `ResolvePolicy::Fail` to return with
62    /// // `modio::download::Error::MultipleFilesFound` as source error.
63    /// let action = DownloadAction::Version {
64    ///     game_id: Id::new(5),
65    ///     mod_id: Id::new(19),
66    ///     version: "0.1".to_string(),
67    ///     policy: ResolvePolicy::Latest,
68    /// };
69    /// let mut chunked = modio.download(action).chunked().await?;
70    ///
71    /// while let Some(chunk) = chunked.data().await {
72    ///     println!("Bytes: {:?}", chunk?);
73    /// }
74    /// #    Ok(())
75    /// # }
76    /// ```
77    fn download<A>(&self, action: A) -> Downloader<'_, Init<'_>>
78    where
79        DownloadAction: From<A>;
80}
81
82impl Download for Client {
83    fn download<A>(&self, action: A) -> Downloader<'_, Init<'_>>
84    where
85        DownloadAction: From<A>,
86    {
87        Downloader::<Init<'_>>::new(self, action.into())
88    }
89}
90
91/// A `Downloader` can be used to stream a mod file or save the file to a local file.
92/// Constructed with [`Download::download`].
93pub struct Downloader<'a, State> {
94    state: State,
95    phantom: PhantomData<fn(&'a State) -> State>,
96}
97
98impl<T> Downloader<'_, T> {
99    pub(crate) fn new(http: &Client, action: DownloadAction) -> Downloader<'_, Init<'_>> {
100        Downloader {
101            state: Init { http, action },
102            phantom: PhantomData,
103        }
104    }
105}
106
107/// Downloader state where the caller must choose how the file is downloaded.
108#[doc(hidden)]
109pub struct Init<'a> {
110    http: &'a Client,
111    action: DownloadAction,
112}
113
114impl<'a> Downloader<'a, Init<'a>> {
115    /// Retrieve the mod file in chunks of `Bytes`.
116    ///
117    /// # Example
118    /// ```no_run
119    /// # use modio::types::id::Id;
120    /// # use modio::util::Download;
121    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
122    /// #     let modio = modio::Client::builder("api-key".to_owned()).build()?;
123    /// let action = modio::util::download::DownloadAction::Primary {
124    ///     game_id: Id::new(5),
125    ///     mod_id: Id::new(19),
126    /// };
127    ///
128    /// let mut chunked = modio.download(action).chunked().await?;
129    /// while let Some(bytes) = chunked.data().await {
130    ///     println!("Bytes: {:?}", bytes);
131    /// }
132    /// #     Ok(())
133    /// # }
134    /// ```
135    pub async fn chunked(self) -> Result<Downloader<'a, Chunked>, Error> {
136        let Init { http, action } = self.state;
137        let info = info::download_info(http, action).await?;
138
139        let req = http::Request::get(info.download_url.as_str())
140            .body(RequestBody::empty())
141            .map_err(Error::request)?;
142
143        let response = http.raw_request(req).await.map_err(Error::request)?;
144
145        Ok(Downloader {
146            state: Chunked::new(info, response),
147            phantom: PhantomData,
148        })
149    }
150
151    /// Save the mod file to a local file.
152    ///
153    /// # Example
154    /// ```no_run
155    /// # use modio::types::id::Id;
156    /// # use modio::util::Download;
157    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
158    /// #     let modio = modio::Client::builder("api-key".to_owned()).build()?;
159    /// let action = modio::util::download::DownloadAction::Primary {
160    ///     game_id: Id::new(5),
161    ///     mod_id: Id::new(19),
162    /// };
163    ///
164    /// modio.download(action).save_to_file("mod.zip").await?;
165    /// #     Ok(())
166    /// # }
167    /// ```
168    pub async fn save_to_file(self, path: impl AsRef<Path>) -> Result<(), Error> {
169        let mut chunked = self.chunked().await?;
170
171        let out = File::create(path)
172            .await
173            .map_err(|err| Error::new(ErrorKind::Io).with(err))?;
174        let mut out = BufWriter::with_capacity(512 * 512, out);
175
176        while let Some(chunk) = chunked.data().await {
177            out.write_all(&chunk?)
178                .await
179                .map_err(|err| Error::new(ErrorKind::Io).with(err))?;
180        }
181        Ok(())
182    }
183}
184
185/// Downloader state where the caller
186#[doc(hidden)]
187pub struct Chunked {
188    info: Info,
189    headers: HeaderMap,
190    body: BodyDataStream<Body>,
191}
192
193impl Chunked {
194    fn new(info: Info, response: Response) -> Self {
195        let (parts, body) = response.into_parts();
196        let headers = parts.headers;
197        let body = body.into_data_stream();
198
199        Self {
200            info,
201            headers,
202            body,
203        }
204    }
205}
206
207impl Downloader<'_, Chunked> {
208    pub fn info(&self) -> &Info {
209        &self.state.info
210    }
211
212    pub fn headers(&self) -> &HeaderMap {
213        &self.state.headers
214    }
215
216    pub async fn data(&mut self) -> Option<Result<Bytes, Error>> {
217        let chunk = self.state.body.next().await;
218        chunk.map(|c| c.map_err(Error::body))
219    }
220}
221
222impl<'a> fmt::Debug for Downloader<'a, Init<'a>> {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.debug_struct("Downloader")
225            .field("action", &self.state.action)
226            .finish_non_exhaustive()
227    }
228}
229
230impl fmt::Debug for Downloader<'_, Chunked> {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.debug_struct("Downloader")
233            .field("info", &self.state.info)
234            .finish_non_exhaustive()
235    }
236}
237
238mod private {
239    use crate::client::Client;
240
241    pub trait Sealed {}
242
243    impl Sealed for Client {}
244}