diff --git a/Cargo.toml b/Cargo.toml index b455293..7fbcdc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "modrinth-api" -version = "0.1.0" +version = "0.1.1" edition = "2024" [dependencies] diff --git a/TODO.md b/TODO.md index ac26cf6..96197d3 100644 --- a/TODO.md +++ b/TODO.md @@ -5,10 +5,9 @@ ## structs\project.rs: -- [ ] - Implement custom struct for `client_side` and `server_side` values -- [ ] - Implement custom struct for `status` value -- [ ] - Implement custom struct for `requested_status` value (w/ Option) -- [ ] - implement custom deserializer for URL links -- [ ] - implement custom struct for donation links -- [ ] - implement custom struct for license +- [x] - Implement custom struct for `client_side` and `server_side` values +- [x] - Implement custom struct for `status` value +- [x] - Implement custom struct for `requested_status` value (w/ Option) +- [x] - implement custom struct for donation links +- [x] - implement custom struct for license - [x] - implement custom `ProjectStatus` struct diff --git a/src/api/search.rs b/src/api/search.rs index fb7fc48..cea635e 100644 --- a/src/api/search.rs +++ b/src/api/search.rs @@ -126,10 +126,65 @@ mod tests { ) .await?; - let title = response.hits.first().unwrap().slug.as_ref(); + let response = response.hits.first().unwrap(); + let title = response.slug.as_ref(); assert!(title.is_some()); assert_eq!(title.ok_or(0), Ok(&String::from("xaeros-minimap"))); Ok(()) } + + #[tokio::test] + async fn test_fetching_project_with_mut() -> Result<()> { + let api = ModrinthAPI::default(); + let response = api + .extended_search( + "xaeros", + &Sort::Downloads, + None, + Some(ExtendedSearch { + offset: None, + facets: vec![vec![Facet::ProjectType(ProjectType::Mod)]], + }), + ) + .await?; + + let response = response.hits.first().unwrap(); + let title = response.slug.as_ref(); + + assert!(title.is_some()); + assert_eq!(title.ok_or(0), Ok(&String::from("xaeros-minimap"))); + + let hit = response.to_owned().fetch_project(&api).await?; + + assert_eq!(hit.slug.as_ref().unwrap(), &String::from("xaeros-minimap")); + Ok(()) + } + + #[tokio::test] + async fn test_fetching_project_without_mut() -> Result<()> { + let api = ModrinthAPI::default(); + let response = api + .extended_search( + "xaeros", + &Sort::Downloads, + None, + Some(ExtendedSearch { + offset: None, + facets: vec![vec![Facet::ProjectType(ProjectType::Mod)]], + }), + ) + .await?; + + let response = response.hits.first().unwrap(); + let title = response.slug.as_ref(); + + assert!(title.is_some()); + assert_eq!(title.ok_or(0), Ok(&String::from("xaeros-minimap"))); + + let hit = response.get_full_project(&api).await?; + + assert_eq!(hit.slug, String::from("xaeros-minimap")); + Ok(()) + } } diff --git a/src/structs/mod.rs b/src/structs/mod.rs index 129e1c2..e5f9545 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -1,28 +1,8 @@ pub mod projects; pub mod search; +use crate::{ModrinthAPI, Result, structs::projects::Project}; use serde::{Deserialize, Serialize}; use url::Url; pub type Date = chrono::DateTime; - -fn deserialise_optional_url<'de, D: serde::Deserializer<'de>>( - de: D, -) -> Result, D::Error> { - use serde::de::{Error, Unexpected}; - use std::borrow::Cow; - - let intermediate = >>::deserialize(de)?; - match intermediate.as_deref() { - None | Some("") => Ok(None), - Some(s) => Url::parse(s).map_or_else( - |err| { - Err(Error::invalid_value( - Unexpected::Str(s), - &err.to_string().as_str(), - )) - }, - |ok| Ok(Some(ok)), - ), - } -} diff --git a/src/structs/projects.rs b/src/structs/projects.rs index 0abc009..a1ed59c 100644 --- a/src/structs/projects.rs +++ b/src/structs/projects.rs @@ -2,10 +2,10 @@ //! //! [documentation](https://docs.modrinth.com/api/operations/tags/projects/) -use super::Date; +use super::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Project { /// The slug of a project, used for vanity URLs pub slug: String, @@ -16,45 +16,30 @@ pub struct Project { /// A list of the categories that the project has pub categories: Vec, /// The client side support of the project - pub client_side: String, // TODO: read #1 in TOOD.md file (structs\projects section) + pub client_side: ProjectSupportRange, /// The server side support of the project - pub server_side: String, // TODO: read #1 in TODO.md file (structs\projects section) + pub server_side: ProjectSupportRange, /// A long form description of the project pub body: String, /// The status of the project - /// - /// TODO: read #2 in TODO.md file (structs\projects section) - pub status: String, + pub status: ProjectStatus, /// The requested status when submitting for review or scheduling the project for release - /// - /// TODO: read #3 in TODO.md file (structs\projects section) - pub requested_status: Option, + pub requested_status: Option, /// A list of categories which are searchable but non-primary pub additional_categories: Vec, /// An optional link to where to submit bugs or issues with the project - /// - /// TODO: read #4 in TODO.md file (structs\projects section) pub issues_url: Option, /// An optional link to the source code of the project - /// - /// TODO: read #4 in TODO.md file (structs\projects section) pub source_url: Option, /// An optional link to the project’s wiki page or other relevant information - /// - /// TODO: read #4 in TODO.md file (structs\projects section) pub wiki_url: Option, /// An optional invite link to the project’s discord - /// - /// TODO: read #4 in TODO.md file (structs\projects section) pub discord_url: Option, - /// Donation links - /// - /// TODO: read #5 in TODO.md file (structs\projects section) - #[serde(skip)] - pub donation_links: Option>, + /// Donation links / urls + pub donation_urls: Vec, pub project_type: String, pub downloads: usize, - /// TODO: read #4 in TODO.md file (structs\projects section) + /// Project icon URL pub icon_url: Option, /// The RGB color of the project, automatically generated from the project icon pub color: Option, @@ -70,9 +55,7 @@ pub struct Project { pub approved: Option, pub queued: Option, pub followers: usize, - /// TODO: read #6 in TODO.md file (structs\projects section) - #[serde(skip)] - pub license: Option>, // placeholder + pub license: License, /// A list of the version IDs of the project (will never be empty unless draft status) pub versions: Vec, /// A list of all the game versions supported by the project @@ -81,6 +64,16 @@ pub struct Project { pub loaders: Vec, } +impl Project { + /// Returns a read-only reference to the project's donation URLs. + /// + /// This method serves as an alias/getter for the [`Project::donation_urls`] field, + /// providing direct access to the same underlying data. + pub fn donation_links(&self) -> &Vec { + self.donation_urls.as_ref() + } +} + #[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ProjectType { @@ -101,3 +94,51 @@ pub enum MonetizationStatus { Demonetized, ForceDemonetized, } + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct License { + pub id: String, + pub name: String, + pub url: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ProjectSupportRange { + Required, + Optional, + Unsupported, + Unknown, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ProjectStatus { + Approved, + Archived, + Rejected, + Draft, + Unlisted, + Processing, + Withheld, + Scheduled, + Private, + Unknown, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RequestedStatus { + Approved, + Archived, + Unlisted, + Private, + Draft, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct DonationLink { + pub id: String, + pub platform: String, + pub url: String, +} diff --git a/src/structs/search.rs b/src/structs/search.rs index 021d005..d9d021a 100644 --- a/src/structs/search.rs +++ b/src/structs/search.rs @@ -7,6 +7,124 @@ pub struct ExtendedSearch { pub facets: Vec>, } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Response { + pub hits: Vec, + /// The number of results that were skipped by the query + pub offset: usize, + /// The number of results that were returned by the query + pub limit: usize, + /// The total number of results that match the query + pub total_hits: usize, +} + +impl Response { + pub fn show_hits(&self) { + self.hits.iter().for_each(|h| println!("{h}")); + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct SearchHit { + /// The project's slug, used for vanity URLs. + pub slug: Option, + pub title: String, + pub description: String, + pub categories: Vec, + pub client_side: projects::ProjectSupportRange, + pub server_side: projects::ProjectSupportRange, + pub project_type: projects::ProjectType, + pub downloads: usize, + pub icon_url: Option, + /// The RGB color of the project, automatically generated from the project icon + pub color: Option, + /// The ID of the moderation thread associated with this project + pub thread_id: Option, + pub monetization_status: Option, + pub project_id: String, + /// Author + pub author: String, + /// A list of the project's primary/featured categories + pub display_categories: Vec, + #[serde(rename = "versions")] + /// A list of all the game versions supported by the project + pub game_versions: Vec, + pub follows: usize, + pub date_created: Date, + pub date_modified: Date, + /// The latest game version that this project supports + pub latest_version: String, + /// The SPDX license ID of a project + pub license: String, + pub gallery: Vec, + pub featured_gallery: Option, + + #[serde(skip)] + pub project_info: Option, +} + +impl SearchHit { + /// Fetches the full project details for this search hit from the Modrinth API. + /// + /// A `SearchHit` provides a summarized view of a project. This asynchronous method + /// retrieves the complete [`Project`] data from the Modrinth API using the + /// [`self.project_id`] and populates the [`self.project_info`] field. + /// + /// This method consumes `self` and returns a modified `SearchHit` instance + /// with the `project_info` field updated. + /// + /// # Arguments + /// + /// * `api` - A reference to the [`ModrinthAPI`] client instance, used to perform the API request. + /// + /// # Returns + /// + /// `Result`: + /// - `Ok(self)`: The updated `SearchHit` instance with `self.project_info` populated + /// with the full [`Project`] data. + /// - `Err(crate::error::Error)`: An error occurred during the API request or data processing, + /// e.g., network issues, invalid project ID, rate limiting, or API response errors. + /// + pub async fn fetch_project(mut self, api: &ModrinthAPI) -> Result { + let result = api.get_project_by_id(self.project_id.as_str()).await?; + self.project_info = Some(result); + Ok(self) + } + + /// Retrieves the complete [`Project`] data for this search hit from the Modrinth API. + /// + /// This asynchronous method fetches the full [`Project`] object corresponding to + /// the [`self.project_id`] without modifying the `SearchHit` instance itself. + /// This is useful when you only need the full project data temporarily or in a + /// context where `SearchHit` ownership is not desired. + /// + /// # Arguments + /// + /// * `api` - A reference to the [`ModrinthAPI`] client instance, used to perform the API request. + /// + /// # Returns + /// + /// `Result`: + /// - `Ok(Project)`: The full [`Project`] data. + /// - `Err(crate::error::Error)`: An error occurred during the API request or data processing. + pub async fn get_full_project(&self, api: &ModrinthAPI) -> Result { + api.get_project_by_id(self.project_id.as_str()).await + } +} + +impl Display for SearchHit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Title: {} ({} downloads)\nAuthor: {}\nCategories: {}", + self.title, + self.downloads, + self.author, + self.display_categories.join(",") + ) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Sort { Relevance, @@ -55,7 +173,7 @@ pub enum Facet { } impl Serialize for Facet { - fn serialize(&self, serializer: S) -> Result + fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { @@ -102,73 +220,3 @@ impl Display for Facet { write!(f, "{}", str) } } - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct Response { - pub hits: Vec, - /// The number of results that were skipped by the query - pub offset: usize, - /// The number of results that were returned by the query - pub limit: usize, - /// The total number of results that match the query - pub total_hits: usize, -} - -impl Response { - pub fn show_hits(&self) { - self.hits.iter().for_each(|h| println!("{h}")); - } -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct SearchHit { - /// The project's slug, used for vanity URLs. - pub slug: Option, - pub title: String, - pub description: String, - pub categories: Vec, - // TODO: read #1 in TODO.md file (structs\projects section) - // pub client_side: projects::ProjectSupportRange, - // pub server_side: projects::ProjectSupportRange, - - // TODO: read #7 in TODO.md file (structs\projects section) - // pub project_type: projects::ProjectType, - pub downloads: usize, - #[serde(deserialize_with = "deserialise_optional_url")] - pub icon_url: Option, - /// The RGB color of the project, automatically generated from the project icon - pub color: Option, - /// The ID of the moderation thread associated with this project - pub thread_id: Option, - pub monetization_status: Option, - pub project_id: String, - /// Author - pub author: String, - /// A list of the project's primary/featured categories - pub display_categories: Vec, - #[serde(rename = "versions")] - /// A list of all the game versions supported by the project - pub game_versions: Vec, - pub follows: usize, - pub date_created: Date, - pub date_modified: Date, - /// The latest game version that this project supports - pub latest_version: String, - /// The SPDX license ID of a project - pub license: String, - pub gallery: Vec, - pub featured_gallery: Option, -} - -impl Display for SearchHit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Title: {} ({} downloads)\nAuthor: {}\nCategories: {}", - self.title, - self.downloads, - self.author, - self.display_categories.join(",") - ) - } -}