cyclone_mod/
api.rs

1use std::collections::HashMap;
2
3use reqwest::{
4    Client, ClientBuilder, Method, RequestBuilder, StatusCode,
5    header::{HeaderMap, HeaderValue},
6};
7
8use crate::{
9    VERSION,
10    err::{self, delete, get, post, validate},
11    nexus_joiner,
12    request::{
13        CategoryName, Changelog, Endorsements, GameId, GameMod, ModFile, ModFiles, ModId,
14        ModUpdated, RateLimiting, TimePeriod, TrackedModsRaw, Validate,
15    },
16};
17
18/// Top level API handler.
19///
20/// All network calls are handled through here.
21pub struct Api {
22    key: String,
23    client: Client,
24}
25
26impl Api {
27    /// Create a new wrapper with a [personal API key](https://next.nexusmods.com/settings/api-keys).
28    ///
29    /// Ideally should be checked with [`Api::validate`] right after:
30    ///
31    /// # Examples
32    ///
33    /// ```no_run
34    /// # use cyclone::Api;
35    /// # tokio_test::block_on(async {
36    /// let api = Api::new("here is my custom key");
37    /// assert!(api.validate().await.is_ok());
38    /// # })
39    /// ```
40    pub fn new<S: Into<String>>(key: S) -> Self {
41        let key = key.into();
42        let client = ClientBuilder::new().default_headers({
43            let mut h = HeaderMap::new();
44            h.insert("apikey", key.parse().unwrap());
45            h.insert("accept", HeaderValue::from_static("application/json"));
46            h
47        });
48        Self {
49            key,
50            client: client.build().expect("oops"),
51        }
52    }
53
54    pub(crate) fn key(&self) -> &str {
55        &self.key
56    }
57
58    fn build(
59        &self,
60        method: Method,
61        ver: &str,
62        slugs: &[&str],
63        params: &[(&'static str, &str)],
64    ) -> RequestBuilder {
65        self.client
66            .request(method, nexus_joiner!(ver, slugs))
67            .query(params)
68    }
69
70    // TODO: Add rate limiting checking.
71}
72
73/// User related methods.
74///
75/// # Status
76///
77/// - [x] `GET`    [`v1/users/validate`](`Api::validate`)
78/// - [x] `GET`    [`v1/user/tracked_mods`](`Api::tracked_mods`)
79/// - [x] `POST`   [`v1/user/tracked_mods`](`Api::track_mod`)
80/// - [x] `DELETE` [`v1/user/tracked_mods`](`Api::untrack_mod`)
81/// - [x] `GET`    [`v1/user/endorsements`](`Api::endorsements`)
82impl Api {
83    /// Validate API key and retrieve user details.
84    ///
85    /// # Examples
86    ///
87    /// ```no_run
88    /// # use cyclone::{Api, err::validate::ValidateError};
89    /// # #[tokio::main]
90    /// # async fn main() -> Result<(), ValidateError> {
91    /// let api = Api::new("...");
92    /// // I am a premium user!
93    /// assert!(api.validate().await?.is_premium());
94    /// # Ok(())
95    /// # }
96    /// ```
97    pub async fn validate(&self) -> Result<Validate, validate::ValidateError> {
98        let response = self
99            .build(Method::GET, VERSION, &["users", "validate"], &[])
100            .send()
101            .await?;
102
103        match response.status() {
104            StatusCode::OK => response
105                .json()
106                .await
107                .map_err(validate::ValidateError::Reqwest),
108            StatusCode::UNAUTHORIZED => Err(validate::ValidateError::InvalidAPIKey(
109                response.json().await?,
110            )),
111            StatusCode::UNPROCESSABLE_ENTITY => {
112                unimplemented!(
113                    "I have not yet encountered this return code but it is listed as a valid return code"
114                );
115            }
116            _ => unreachable!("The only three documented return codes are 200, 404 (401), and 422"),
117        }
118    }
119
120    /// Get a list of the user's tracked mods.
121    ///
122    /// # Notes
123    /// Consider converting to [`TrackedMods`](`crate::request::TrackedMods`).
124    pub async fn tracked_mods(&self) -> Result<TrackedModsRaw, validate::ValidateError> {
125        let response = self
126            .build(Method::GET, VERSION, &["user", "tracked_mods"], &[])
127            .send()
128            .await?;
129
130        match response.status() {
131            StatusCode::OK => response
132                .json()
133                .await
134                .map_err(validate::ValidateError::Reqwest),
135            StatusCode::UNAUTHORIZED => Err(validate::ValidateError::InvalidAPIKey(
136                response.json().await?,
137            )),
138            StatusCode::UNPROCESSABLE_ENTITY => {
139                unimplemented!(
140                    "I have not yet encountered this return code but it is listed as a valid return code"
141                );
142            }
143            _ => unreachable!("The only three documented return codes are 200, 404 (401), and 422"),
144        }
145    }
146
147    /// Track a mod based on a `u64` mod ID.
148    pub async fn track_mod<T: Into<u64>>(
149        &self,
150        game: &str,
151        id: T,
152    ) -> Result<post::PostModStatus, post::TrackModError> {
153        let id = id.into();
154        let response = self
155            .build(Method::POST, VERSION, &["user", "tracked_mods"], &[])
156            .query(&[("domain_name", game)])
157            .form(&HashMap::from([("mod_id", id)]))
158            .send()
159            .await?;
160
161        match response.status() {
162            StatusCode::OK => Ok(post::PostModStatus::AlreadyTracking(ModId::from_u64(id))),
163            StatusCode::CREATED => Ok(post::PostModStatus::SuccessfullyTracked(ModId::from_u64(
164                id,
165            ))),
166            StatusCode::UNAUTHORIZED => {
167                Err(response.json::<err::InvalidAPIKeyError>().await?.into())
168            }
169            StatusCode::NOT_FOUND => Err(response.json::<err::ModNotFoundError>().await?.into()),
170            _ => unreachable!("The only four documented return codes are 200, 201, 404, and 401"),
171        }
172    }
173
174    /// Untrack a mod.
175    ///
176    /// # Notes
177    /// This function takes in a [`ModId`], not a `u64` because it is assumed that (unlike
178    /// [`Api::track_mod`]) the caller knows of a valid mod ID.
179    pub async fn untrack_mod<T: Into<ModId>>(
180        &self,
181        game: &str,
182        id: T,
183    ) -> Result<(), delete::DeleteModError> {
184        let id = id.into();
185        let response = self
186            .build(Method::DELETE, VERSION, &["user", "tracked_mods"], &[])
187            .query(&[("domain_name", game)])
188            .form(&HashMap::from([("mod_id", id)]))
189            .send()
190            .await?;
191
192        match response.status() {
193            StatusCode::OK => Ok(()),
194            StatusCode::NOT_FOUND => {
195                Err(response.json::<err::UntrackedOrInvalidMod>().await?.into())
196            }
197            _ => unreachable!("The only two documented return codes are 200 and 404"),
198        }
199    }
200
201    /// Get a list of mods the user has endorsed.
202    pub async fn endorsements(&self) -> Result<Endorsements, validate::ValidateError> {
203        let response = self
204            .build(Method::GET, VERSION, &["user", "endorsements"], &[])
205            .send()
206            .await?;
207
208        match response.status() {
209            StatusCode::OK => response
210                .json()
211                .await
212                .map_err(validate::ValidateError::Reqwest),
213            StatusCode::UNAUTHORIZED => Err(validate::ValidateError::InvalidAPIKey(
214                response.json().await?,
215            )),
216            StatusCode::UNPROCESSABLE_ENTITY => {
217                unimplemented!(
218                    "I have not yet encountered this return code but it is listed as a valid return code"
219                );
220            }
221            _ => unreachable!("The only three documented return codes are 200, 404 (401), and 422"),
222        }
223    }
224}
225
226/// Mod related methods.
227///
228/// - [x] `GET`  [`v1/games/{game_domain_name}/mods/updated`](`Api::updated_during`)
229/// - [x] `GET`  `v1/games/{game_domain_name}/mods/{mod_id}/changelogs`
230/// - [ ] `GET`  `v1/games/{game_domain_name}/mods/latest_added`
231/// - [ ] `GET`  `v1/games/{game_domain_name}/mods/latest_updated`
232/// - [ ] `GET`  `v1/games/{game_domain_name}/mods/trending`
233/// - [x] `GET`  `v1/games/{game_domain_name}/mods/{id}`
234/// - [ ] `GET`  `v1/games/{game_domain_name}/mods/md5_search/{md5_hash}`
235/// - [ ] `POST` `v1/games/{game_domain_name}/mods/{id}/endorse`
236/// - [ ] `POST` `v1/games/{game_domain_name}/mods/{id}/abstain`
237impl Api {
238    /// Get a list of mods updated within a timeframe.
239    pub async fn updated_during(
240        &self,
241        game: &str,
242        time: TimePeriod,
243    ) -> Result<Vec<ModUpdated>, get::GameModError> {
244        let response = self
245            .build(
246                Method::GET,
247                VERSION,
248                &["games", game, "mods", "updated"],
249                &[("period", time.as_str())],
250            )
251            .send()
252            .await?;
253
254        match response.status() {
255            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
256            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
257            StatusCode::UNPROCESSABLE_ENTITY => {
258                unimplemented!(
259                    "I have not yet encountered this return code but it is listed as a valid return code"
260                );
261            }
262            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
263        }
264    }
265
266    /// Get changelogs for a mod.
267    pub async fn changelogs<T: Into<ModId>>(
268        &self,
269        game: &str,
270        id: T,
271    ) -> Result<Changelog, get::GameModError> {
272        let id = id.into();
273        let response = self
274            .build(
275                Method::GET,
276                VERSION,
277                &["games", game, "mods", id.to_string().as_str(), "changelogs"],
278                &[],
279            )
280            .send()
281            .await?;
282
283        match response.status() {
284            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
285            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
286            StatusCode::UNPROCESSABLE_ENTITY => {
287                unimplemented!(
288                    "I have not yet encountered this return code but it is listed as a valid return code"
289                );
290            }
291            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
292        }
293    }
294
295    /// Get specific mod information.
296    pub async fn mod_info<T: Into<ModId>>(
297        &self,
298        game: &str,
299        id: T,
300    ) -> Result<GameMod, get::GameModError> {
301        let id = id.into();
302        let response = self
303            .build(
304                Method::GET,
305                VERSION,
306                &["games", game, "mods", id.to_string().as_str()],
307                &[],
308            )
309            .send()
310            .await?;
311
312        match response.status() {
313            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
314            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
315            StatusCode::UNPROCESSABLE_ENTITY => {
316                unimplemented!(
317                    "I have not yet encountered this return code but it is listed as a valid return code"
318                );
319            }
320            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
321        }
322    }
323}
324
325/// Game related methods.
326///
327/// - [x] `GET` [`v1/games`](`Api::games`)
328/// - [x] `GET` [`v1/games/{game_domain_name}`](`Api::game`)
329impl Api {
330    /// Get a list of all games tracked by NexusMods.
331    pub async fn games(&self) -> Result<Vec<GameId>, get::GameModError> {
332        let response = self
333            .build(Method::GET, VERSION, &["games"], &[])
334            .send()
335            .await?;
336
337        match response.status() {
338            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
339            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
340            StatusCode::UNPROCESSABLE_ENTITY => {
341                unimplemented!(
342                    "I have not yet encountered this return code but it is listed as a valid return code"
343                );
344            }
345            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
346        }
347    }
348
349    /// Get information about a single game.
350    pub async fn game(&self, game: &str) -> Result<GameId, get::GameModError> {
351        let response = self
352            .build(Method::GET, VERSION, &["games", game], &[])
353            .send()
354            .await?;
355
356        match response.status() {
357            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
358            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
359            StatusCode::UNPROCESSABLE_ENTITY => {
360                unimplemented!(
361                    "I have not yet encountered this return code but it is listed as a valid return code"
362                );
363            }
364            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
365        }
366    }
367}
368
369/// Mod file related methods.
370///
371/// - [x] `GET` [`v1/games/{game_domain_name}/mods/{mod_id}/files`](`Api::mod_files`)
372/// - [x] `GET` [`v1/games/{game_domain_name}/mods/{mod_id}/files/{file_id}`](`Api::mod_file`)
373/// - [ ] `GET` `v1/games/{game_domain_name}/mods/{mod_id}/files/{id}/download_link`
374impl Api {
375    /// Based on a game and a [`ModId`], get data about the download files the mod provides.
376    pub async fn mod_files<S: Into<ModId>>(
377        &self,
378        game: &str,
379        mod_id: S,
380        category: Option<CategoryName>,
381    ) -> Result<ModFiles, get::GameModError> {
382        let mod_id = mod_id.into();
383        let response = self
384            .build(
385                Method::GET,
386                VERSION,
387                &["games", game, "mods", mod_id.to_string().as_str(), "files"],
388                &category
389                    .iter()
390                    .map(|c| ("category", c.to_header_str()))
391                    .collect::<Vec<_>>(),
392            )
393            .send()
394            .await?;
395
396        match response.status() {
397            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
398            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
399            StatusCode::UNPROCESSABLE_ENTITY => {
400                unimplemented!(
401                    "I have not yet encountered this return code but it is listed as a valid return code"
402                );
403            }
404            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
405        }
406    }
407
408    pub async fn mod_file<S: Into<ModId>>(
409        &self,
410        game: &str,
411        mod_id: S,
412        file_id: u64,
413    ) -> Result<ModFile, get::GameModError> {
414        let mod_id = mod_id.into();
415        let response = self
416            .build(
417                Method::GET,
418                VERSION,
419                &[
420                    "games",
421                    game,
422                    "mods",
423                    mod_id.to_string().as_str(),
424                    "files",
425                    file_id.to_string().as_str(),
426                ],
427                &[],
428            )
429            .send()
430            .await?;
431
432        match response.status() {
433            StatusCode::OK => response.json().await.map_err(get::GameModError::Reqwest),
434            StatusCode::NOT_FOUND => Err(response.json::<err::InvalidAPIKeyError>().await?.into()),
435            StatusCode::UNPROCESSABLE_ENTITY => {
436                unimplemented!(
437                    "I have not yet encountered this return code but it is listed as a valid return code"
438                );
439            }
440            _ => unreachable!("The only three documented return codes are 200, 404, and 422"),
441        }
442    }
443}