├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.txt ├── README.md └── src ├── api_calls ├── file_calls.rs ├── fingerprint_calls.rs ├── mod.rs └── mod_calls.rs ├── lib.rs ├── request.rs └── structures ├── common_structs.rs ├── file_structs.rs ├── fingerprint_structs.rs ├── mod.rs └── mod_structs.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /target 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## `1.6.1` 4 | ### 02.04.2025 5 | 6 | - Don't use `reqwest`'s `.json()` method, instead use `serde_json::from_slice` directly. This will give better and more useful error messages when deserialisation fails. 7 | - Added `JsonError(serde_json::Error)` variant to `furse::Error`. 8 | - Make `File.modules` nullable to fix [gorilla-devs/ferium#465](https://github.com/gorilla-devs/ferium/issues/465). 9 | 10 | ## `1.6.0` 11 | ### 31.03.2025 12 | 13 | - Remove `once_cell` and use `std::LazyLock` instead, and bump `thiserror` to `2.0` 14 | - Improve and update README 15 | - Update doc-tests to use `tokio_test::block_on` 16 | - Improve structs and API call documentation and examples 17 | - Change the API of `Furse.get_files()` to return a vector of `Option`s to account for files that were not found 18 | - Derive `Copy` wherever possible 19 | - Update missing fields/variants in structs/enums 20 | 21 | ## `1.5.10` 22 | ### 17.06.2023 23 | 24 | - Remove `deny_unknown_fields` 25 | - Update dependencies, replace `lazy_static` with `once_cell` 26 | - Update fields and documentation 27 | 28 | ## `1.5.6` 29 | ### 12.11.2022 30 | 31 | - Update dependencies 32 | - Get `get_mods` API call 33 | - Remove redundant doc comments 34 | 35 | ## `1.5.3` 36 | ### 03.09.2022 37 | 38 | - Update dependencies 39 | - Remove `bytes` dependency 40 | 41 | ## `1.5.1` 42 | ### 10.07.2022 43 | 44 | - Fixed a bug where if an empty string was provided for an `Option` field, the deserialiser would try to parse the url and error out rather than return `None`. 45 | - Renamed the `Datetime` alias to `UtcTime` 46 | 47 | ## `1.4.0` 48 | ### 15.06.2022 49 | 50 | - Dependencies are specified with `~` 51 | - Extract the fingerprint calculation to a separate function 52 | 53 | ## `1.3.0` 54 | ### 03.06.2022 55 | 56 | - Replaced the mess of number types in `structures` with `Number` which is an alias for `usize` 57 | - Added the `get_fingerprint_matches()` fingerprint call and it's relevant structs 58 | 59 | ## `1.2.3` 60 | ### 31.05.2022 61 | 62 | Add the Quilt mod loader to `ModLoaderType` 63 | 64 | ## `1.1.2` 65 | ### 14.05.2022 66 | 67 | - Remove `download_mod_file_from_file()` and `download_mod_file_from_file_id` 68 | - Add `file_download_url()` to get the download url of a file 69 | 70 | ## `1.1.1` 71 | ### 05.05.2022 72 | 73 | Make the `logo` field of `Mod` nullable because CurseForge is having an issue where some of the mods' logos are null. 74 | 75 | ## `1.1.0` 76 | ### 01.05.2022 77 | 78 | - Add url parse error 79 | - Make the tests actually capture errors 80 | - Improve requests to use url parsing 81 | - All structs consistently use the following: 82 | ```rust 83 | #[derive(Deserialize, Serialize, Debug, Clone)] 84 | #[serde(rename_all = "camelCase")] 85 | ``` 86 | - All enums consistently use the following: 87 | ```rust 88 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, PartialEq, Eq)] 89 | #[repr(u8)] 90 | ``` 91 | - Added missing fields for `Mod` 92 | 93 | ## [1.0.3] - 05.03.2022 94 | 95 | - Removed `file_id` field of `FileDependency` 96 | 97 | ## [1.0.2] - 02.02.2022 98 | 99 | - Implement `Debug` and `Clone` for `Furse` 100 | 101 | ## [1.0.1] - 23.01.2022 102 | 103 | - Make `get_mod_files()` use a pagesize of 10000 because paginations are ignored 104 | 105 | ## [1.0.0] - 22.01.2022 106 | 107 | Initial release 108 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "furse" 3 | version = "1.6.1" 4 | edition = "2021" 5 | authors = ["Ilesh Thiada "] 6 | description = "A simple Rust wrapper for the official CurseForge API" 7 | repository = "https://github.com/gorilla-devs/furse" 8 | license = "MIT" 9 | publish = ["crates-io"] 10 | keywords = ["curseforge", "minecraft", "modding"] 11 | categories = ["api-bindings"] 12 | 13 | [dependencies] 14 | reqwest = { version = "0.12", default-features = false, features = [ 15 | "json", 16 | "rustls-tls", 17 | ] } 18 | chrono = { version = "0.4", features = ["serde"] } 19 | serde = { version = "1.0", features = ["derive"] } 20 | url = { version = "2.5", features = ["serde"] } 21 | serde_json = "1.0" 22 | serde_repr = "0.1" 23 | thiserror = "2.0" 24 | murmur2 = "0.1" 25 | 26 | [dev-dependencies] 27 | tokio-test = "0.4" 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 theRookieCoder 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Furse 2 | 3 | [![Made with Rust](https://img.shields.io/badge/Made_with-Rust-b11522?labelColor=e82833&logo=rust)](https://www.rust-lang.org) 4 | [![GitHub - Furse](https://img.shields.io/badge/GitHub-Furse-8E47FE?logo=github)](https://github.com/gorilla-devs/furse) 5 | [![license - MIT](https://img.shields.io/github/license/gorilla-devs/furse)](https://github.com/gorilla-devs/furse/blob/master/LICENSE.txt) 6 | [![crates.io](https://img.shields.io/crates/v/furse?logo=rust)](https://crates.io/crates/furse) 7 | [![docs.rs](https://img.shields.io/docsrs/furse/latest?label=docs.rs&logo=docsdotrs)](https://docs.rs/furse) 8 | 9 | Furse is a simple library for using the [CurseForge REST API](https://docs.curseforge.com/rest-api#accessing-the-service) in Rust. 10 | It uses [reqwest](https://docs.rs/reqwest) for sending requests and deserialising responses to strongly typed structs using [Serde](https://serde.rs). 11 | 12 | ## Features 13 | 14 | - Strongly typed structures for API responses 15 | - Useful examples in the method documentations 16 | - Implementations for the following API calls 17 | - [Get mod by mod ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mod_file) ([official documentation](https://docs.curseforge.com/rest-api#get-mod)) 18 | - [Get mods by mod IDs](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mods) ([official documentation](https://docs.curseforge.com/rest-api#get-mods)) 19 | - [Get HTML description by mod ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mod_description) ([official documentation](https://docs.curseforge.com/rest-api#get-mod-description)) 20 | - [Get all of the mod's files by mod ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mod_files) ([official documentation](https://docs.curseforge.com/rest-api#get-mod-files)) 21 | - [Get file by mod ID and file ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mod_file) ([official documentation](https://docs.curseforge.com/rest-api#get-mod-file)) 22 | - [Get files by file IDs](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_files) ([official documentation](https://docs.curseforge.com/rest-api#get-files)) 23 | - [Get file's HTML changelog by mod ID and file ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_mod_file_changelog) ([official documentation](https://docs.curseforge.com/rest-api#get-mod-file-changelog)) 24 | - [Get file's download URL by mod ID and file ID](https://docs.rs/furse/latest/furse/struct.Furse.html#method.file_download_url) ([official documentation](https://docs.curseforge.com/rest-api#get-mod-file-download-url)) 25 | - [Get files that match the given fingerprints](https://docs.rs/furse/latest/furse/struct.Furse.html#method.get_fingerprint_matches) ([official documentation](https://docs.curseforge.com/rest-api#get-fingerprints-matches)) 26 | -------------------------------------------------------------------------------- /src/api_calls/file_calls.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | request::API_URL_BASE, 3 | structures::{file_structs::*, ID}, 4 | Furse, Result, 5 | }; 6 | 7 | impl Furse { 8 | /// Get all the files of mod with `mod_id` 9 | /// 10 | /// ## Example 11 | /// ```rust 12 | /// # tokio_test::block_on(async { 13 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 14 | /// // Get the Terralith mod's files 15 | /// let terralith_files = curseforge.get_mod_files(513688).await?; 16 | /// // Check that the latest file is downloadable 17 | /// assert!(terralith_files[0].is_available); 18 | /// # Ok::<_, furse::Error>(()) }).unwrap() 19 | /// ``` 20 | pub async fn get_mod_files(&self, mod_id: ID) -> Result> { 21 | let mut url = API_URL_BASE 22 | .join("mods/")? 23 | .join(&(mod_id.to_string() + "/"))? 24 | .join("files")?; 25 | url.set_query(Some("pageSize=10000")); 26 | Ok(self.get(url).await?.data) 27 | } 28 | 29 | /// Get the file with `file_id` of mod with `mod_id` 30 | /// 31 | /// ## Example 32 | /// ```rust 33 | /// # tokio_test::block_on(async { 34 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 35 | /// // Get the Terralith mod's v2.0.12 file 36 | /// let terralith_file = curseforge.get_mod_file(513688, 3606078).await?; 37 | /// // Check that it contains the version in the file name 38 | /// assert!(terralith_file.file_name.contains("v2.0.12")); 39 | /// # Ok::<_, furse::Error>(()) }).unwrap() 40 | /// ``` 41 | pub async fn get_mod_file(&self, mod_id: ID, file_id: ID) -> Result { 42 | Ok(self 43 | .get( 44 | API_URL_BASE 45 | .join("mods/")? 46 | .join(&(mod_id.to_string() + "/"))? 47 | .join("files/")? 48 | .join(&file_id.to_string())?, 49 | ) 50 | .await? 51 | .data) 52 | } 53 | 54 | /// Get the changelog of the file with `file_id` of mod with `mod_id` in HTML format 55 | /// 56 | /// ## Example 57 | /// ```rust 58 | /// # tokio_test::block_on(async { 59 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 60 | /// // Get the Terralith mod's v2.0.12 file's changelog 61 | /// let changelog = curseforge.get_mod_file_changelog(513688, 3606078).await?; 62 | /// // This update had huge performance updates, so it should be mentioned in the changelog 63 | /// assert!(changelog.contains("performance")); 64 | /// # Ok::<_, furse::Error>(()) }).unwrap() 65 | /// ``` 66 | pub async fn get_mod_file_changelog(&self, mod_id: ID, file_id: ID) -> Result { 67 | Ok(self 68 | .get( 69 | API_URL_BASE 70 | .join("mods/")? 71 | .join(&(mod_id.to_string() + "/"))? 72 | .join("files/")? 73 | .join(&(file_id.to_string() + "/"))? 74 | .join("changelog")?, 75 | ) 76 | .await? 77 | .data) 78 | } 79 | 80 | /// Get the download URL of the file with `file_id` of mod with `mod_id` 81 | /// 82 | /// ## Example 83 | /// ```rust 84 | /// # tokio_test::block_on(async { 85 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 86 | /// // Get information about the file 87 | /// let terralith_mod_file = curseforge.get_mod_file(513688, 3606078).await?; 88 | /// // Get the file's download url 89 | /// let download_url = curseforge.file_download_url(513688, 3606078).await?; 90 | /// // They should be the same url 91 | /// assert_eq!(Some(download_url), terralith_mod_file.download_url); 92 | /// # Ok::<_, furse::Error>(()) }).unwrap() 93 | /// ``` 94 | pub async fn file_download_url(&self, mod_id: ID, file_id: ID) -> Result { 95 | Ok(self 96 | .get( 97 | API_URL_BASE 98 | .join("mods/")? 99 | .join(&(mod_id.to_string() + "/"))? 100 | .join("files/")? 101 | .join(&(file_id.to_string() + "/"))? 102 | .join("download-url")?, 103 | ) 104 | .await? 105 | .data) 106 | } 107 | 108 | /// Get a list of files from the `file_ids` provided 109 | /// 110 | /// This function additionally sorts the returned files in the order you requested them in, 111 | /// and leaves `None` in places where a file was not found. 112 | /// 113 | /// ## Example 114 | /// ```rust 115 | /// # #![feature(assert_matches)] 116 | /// # use std::assert_matches::assert_matches; 117 | /// # use furse::structures::file_structs::File; 118 | /// # tokio_test::block_on(async { 119 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 120 | /// // Try getting 2 real files, and a non-existent one (1234) 121 | /// let files = curseforge.get_files(vec![3144153, 3778436, 1234]).await?; 122 | /// // The first two files should be `Some`, 123 | /// // and their IDs should be in the order we requested them in 124 | /// assert_matches!(files[0], Some(File { id: 3144153, .. })); 125 | /// assert_matches!(files[1], Some(File { id: 3778436, .. })); 126 | /// // But the last one should be `None` as it doesn't exist 127 | /// assert!(files[2].is_none()); 128 | /// # Ok::<_, furse::Error>(()) }).unwrap() 129 | /// ``` 130 | pub async fn get_files(&self, file_ids: Vec) -> Result>> { 131 | #[derive(serde::Serialize)] 132 | #[serde(rename_all = "camelCase")] 133 | struct GetFilesBodyRequestBody { 134 | file_ids: Vec, 135 | } 136 | 137 | let file_ids = GetFilesBodyRequestBody { file_ids }; 138 | let mut files: Vec = self 139 | .post(API_URL_BASE.join("mods/")?.join("files")?, &file_ids) 140 | .await? 141 | .data; 142 | let mut ordered_files = Vec::new(); 143 | for file_id in file_ids.file_ids { 144 | if let Some(index) = files.iter().position(|file| file.id == file_id) { 145 | ordered_files.push(Some(files.swap_remove(index))); 146 | } else { 147 | ordered_files.push(None); 148 | } 149 | } 150 | Ok(ordered_files) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/api_calls/fingerprint_calls.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::structures::fingerprint_structs::*; 3 | 4 | /// Calculate the CurseForge fingerprint for the `bytes` provided 5 | /// 6 | /// CurseForge uses a modified version of [murmur2] where some bytes are stripped, 7 | /// and the resulting bytes are hashes with seed `1` 8 | pub fn cf_fingerprint(bytes: &[u8]) -> usize { 9 | // Implement CF's murmur2 modification 10 | let bytes = bytes 11 | .iter() 12 | .filter(|x| !matches!(x, 9 | 10 | 13 | 32)) 13 | .copied() 14 | .collect::>(); 15 | // Hash the contents using seed `1` 16 | murmur2::murmur2(&bytes, 1) as usize 17 | } 18 | 19 | impl Furse { 20 | /// Get files and mod IDs from the `fingerprints` provided 21 | /// 22 | /// ## Example 23 | /// ```rust 24 | /// # tokio_test::block_on(async { 25 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 26 | /// // Get the Terralith mod's v2.0.12 file 27 | /// let terralith_file = curseforge.get_mod_file(513688, 3606078).await?; 28 | /// // Download the file contents 29 | /// let contents = reqwest::get(terralith_file.download_url.unwrap()) 30 | /// .await? 31 | /// .bytes() 32 | /// .await?; 33 | /// // Hash the contents 34 | /// let fingerprint = furse::cf_fingerprint(&contents); 35 | /// // Get the fingerprint matches 36 | /// let matches = curseforge.get_fingerprint_matches(vec![fingerprint]) 37 | /// .await? 38 | /// .exact_matches; 39 | /// // The resulting file should have the same ID 40 | /// assert_eq!(matches[0].file.id, terralith_file.id); 41 | /// # Ok::<_, furse::Error>(()) }).unwrap() 42 | /// ``` 43 | pub async fn get_fingerprint_matches( 44 | &self, 45 | fingerprints: Vec, 46 | ) -> Result { 47 | #[derive(serde::Serialize)] 48 | #[serde(rename_all = "camelCase")] 49 | struct GetFingerprintMatchesRequestBody { 50 | fingerprints: Vec, 51 | } 52 | 53 | Ok(self 54 | .post( 55 | API_URL_BASE.join("fingerprints")?, 56 | &GetFingerprintMatchesRequestBody { fingerprints }, 57 | ) 58 | .await? 59 | .data) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/api_calls/mod.rs: -------------------------------------------------------------------------------- 1 | mod file_calls; 2 | pub(crate) mod fingerprint_calls; 3 | mod mod_calls; 4 | 5 | use crate::{request::API_URL_BASE, structures::*, Furse, Result}; 6 | 7 | /// API responses are returned in this structure, with the actual results in `data` and optional `pagination` 8 | #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy)] 9 | #[serde(rename_all = "camelCase")] 10 | pub(crate) struct Response { 11 | data: T, 12 | pagination: Option, 13 | } 14 | -------------------------------------------------------------------------------- /src/api_calls/mod_calls.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use mod_structs::*; 3 | 4 | impl Furse { 5 | /// Get mod with ID `mod_id` 6 | /// 7 | /// ## Example 8 | /// ```rust 9 | /// # tokio_test::block_on(async { 10 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 11 | /// // Get the Terralith mod 12 | /// let terralith_mod = curseforge.get_mod(513688).await?; 13 | /// // Check that it is made by Starmute 14 | /// assert_eq!(terralith_mod.authors[0].name, "Starmute"); 15 | /// # Ok::<_, furse::Error>(()) }).unwrap() 16 | /// ``` 17 | pub async fn get_mod(&self, mod_id: ID) -> Result { 18 | Ok(self 19 | .get(API_URL_BASE.join("mods/")?.join(&mod_id.to_string())?) 20 | .await? 21 | .data) 22 | } 23 | 24 | /// Get multiple mods with IDs `mod_ids` 25 | /// 26 | /// ## Example 27 | /// ```rust 28 | /// # tokio_test::block_on(async { 29 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 30 | /// // Get Xaero's Minimap and World Map mods 31 | /// let mods = curseforge.get_mods(vec![263420, 317780]).await?; 32 | /// let [minimap, worldmap, ..] = mods.as_slice() else { 33 | /// panic!("Expected 2 mods, got less"); 34 | /// }; 35 | /// // Check that both are made by `xaero96` 36 | /// assert_eq!(minimap.authors[0].name, "xaero96"); 37 | /// assert_eq!(worldmap.authors[0].name, "xaero96"); 38 | /// # Ok::<_, furse::Error>(()) }).unwrap() 39 | /// ``` 40 | pub async fn get_mods(&self, mod_ids: Vec) -> Result> { 41 | #[derive(serde::Serialize)] 42 | #[serde(rename_all = "camelCase")] 43 | struct GetModsByIdsListRequestBody { 44 | mod_ids: Vec, 45 | } 46 | Ok(self 47 | .post( 48 | API_URL_BASE.join("mods")?, 49 | &GetModsByIdsListRequestBody { mod_ids }, 50 | ) 51 | .await? 52 | .data) 53 | } 54 | 55 | /// Get the description of mod with ID `mod_id` 56 | /// 57 | /// ## Example 58 | /// ```rust 59 | /// # tokio_test::block_on(async { 60 | /// # let curseforge = furse::Furse::new(env!("CURSEFORGE_API_KEY")); 61 | /// // Get the Terralith mod's description 62 | /// let terralith_mod_description = curseforge.get_mod_description(513688).await?; 63 | /// // The description should contain the mod's name 64 | /// assert!(terralith_mod_description.contains("Terralith")); 65 | /// # Ok::<_, furse::Error>(()) }).unwrap() 66 | /// ``` 67 | pub async fn get_mod_description(&self, mod_id: ID) -> Result { 68 | Ok(self 69 | .get( 70 | API_URL_BASE 71 | .join("mods/")? 72 | .join(&(mod_id.to_string() + "/"))? 73 | .join("description")?, 74 | ) 75 | .await? 76 | .data) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | mod api_calls; 4 | mod request; 5 | pub mod structures; 6 | pub use api_calls::fingerprint_calls::cf_fingerprint; 7 | 8 | #[derive(thiserror::Error, Debug)] 9 | #[error(transparent)] 10 | pub enum Error { 11 | ReqwestError(#[from] reqwest::Error), 12 | JsonError(#[from] serde_json::Error), 13 | UrlParseError(#[from] url::ParseError), 14 | } 15 | pub(crate) type Result = std::result::Result; 16 | 17 | /// A container to store the API key and invoke API calls on 18 | /// 19 | /// ## Initialisation 20 | /// ```rust 21 | /// # use furse::Furse; 22 | /// # tokio_test::block_on(async { 23 | /// let curseforge = Furse::new(env!("CURSEFORGE_API_KEY")); 24 | /// // Use the instance to call the API 25 | /// let terralith = curseforge.get_mod(513688).await?; 26 | /// # Ok::<_, furse::Error>(()) }).unwrap() 27 | /// ``` 28 | #[derive(Clone, Debug)] 29 | pub struct Furse { 30 | client: reqwest::Client, 31 | api_key: String, 32 | } 33 | 34 | impl Furse { 35 | /// Create a new API instance 36 | /// 37 | /// Generate an API key in the [console](https://console.curseforge.com/#/api-keys). 38 | /// 39 | /// ```rust 40 | /// # use furse::Furse; 41 | /// let curseforge = Furse::new(env!("CURSEFORGE_API_KEY")); 42 | /// ``` 43 | pub fn new(api_key: impl Into) -> Self { 44 | Self { 45 | client: reqwest::Client::new(), 46 | api_key: api_key.into(), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | use crate::{api_calls::Response, Furse, Result}; 2 | use reqwest::{IntoUrl, Url}; 3 | use serde::{de::DeserializeOwned, Serialize}; 4 | use std::sync::LazyLock; 5 | 6 | pub(crate) static API_URL_BASE: LazyLock = 7 | LazyLock::new(|| Url::parse("https://api.curseforge.com/v1/").unwrap()); 8 | 9 | impl Furse { 10 | /// Perform a GET request to `url` and deserialise to `T` 11 | pub(crate) async fn get(&self, url: impl IntoUrl) -> Result> { 12 | let bytes = self 13 | .client 14 | .get(url) 15 | .header("x-api-key", &self.api_key) 16 | .send() 17 | .await? 18 | .error_for_status()? 19 | .bytes() 20 | .await?; 21 | Ok(serde_json::from_slice(&bytes)?) 22 | } 23 | 24 | /// Perform a POST request to `url` with `body` 25 | pub(crate) async fn post( 26 | &self, 27 | url: impl IntoUrl, 28 | body: &B, 29 | ) -> Result> { 30 | let bytes = self 31 | .client 32 | .post(url) 33 | .json(body) 34 | .header("x-api-key", &self.api_key) 35 | .send() 36 | .await? 37 | .error_for_status()? 38 | .bytes() 39 | .await?; 40 | Ok(serde_json::from_slice(&bytes)?) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/structures/common_structs.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct Category { 6 | pub id: ID, 7 | pub game_id: ID, 8 | pub name: String, 9 | pub slug: String, 10 | pub url: Url, 11 | pub icon_url: Url, 12 | pub date_modified: UtcTime, 13 | /// Whether this is a top level category for other categories 14 | pub is_class: Option, 15 | /// The ID of the class which this category is under 16 | pub class_id: Option, 17 | pub parent_category_id: Option, 18 | pub display_index: Option, 19 | } 20 | 21 | #[derive(Deserialize, Serialize, Debug, Clone)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct SortableGameVersion { 24 | /// Original version name (e.g. `1.5b`) 25 | pub game_version_name: String, 26 | /// Padded version used for sorting (e.g. `0000000001.0000000005`) 27 | pub game_version_padded: String, 28 | /// Clean version (e.g. `1.5`) 29 | pub game_version: String, 30 | pub game_version_release_date: UtcTime, 31 | pub game_version_type_id: Option, 32 | } 33 | 34 | #[derive(Deserialize, Serialize, Debug, Clone, Copy)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct Pagination { 37 | /// Index of the first item included 38 | pub index: Number, 39 | /// Number of items requested 40 | pub page_size: Number, 41 | /// Actual number of items that were returned 42 | pub result_count: Number, 43 | /// Total number of items available 44 | pub total_count: Number, 45 | } 46 | 47 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 48 | #[repr(u8)] 49 | pub enum ModLoaderType { 50 | Any = 0, 51 | Forge = 1, 52 | Cauldron = 2, 53 | LiteLoader = 3, 54 | Fabric = 4, 55 | Quilt = 5, 56 | NeoForge = 6, 57 | } 58 | -------------------------------------------------------------------------------- /src/structures/file_structs.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct File { 6 | pub id: ID, 7 | /// The game ID of the mod that this file belongs to 8 | pub game_id: ID, 9 | pub mod_id: ID, 10 | pub is_available: bool, 11 | pub display_name: String, 12 | /// Exact file name 13 | pub file_name: String, 14 | pub release_type: FileReleaseType, 15 | pub file_status: FileStatus, 16 | pub hashes: Vec, 17 | pub file_date: UtcTime, 18 | /// The file length in bytes 19 | pub file_length: Number, 20 | pub download_count: Number, 21 | pub file_size_on_disk: Option, 22 | #[serde(deserialize_with = "deserialise_optional_url")] 23 | pub download_url: Option, 24 | /// List of game versions this file is relevant for 25 | pub game_versions: Vec, 26 | /// Metadata used for sorting by game versions 27 | pub sortable_game_versions: Vec, 28 | pub dependencies: Vec, 29 | pub expose_as_alternative: Option, 30 | pub parent_project_file_id: Option, 31 | pub alternate_file_id: Option, 32 | pub is_server_pack: Option, 33 | pub server_pack_file_id: Option, 34 | pub is_early_access_content: Option, 35 | pub early_access_end_date: Option, 36 | pub file_fingerprint: Number, 37 | pub modules: Option>, 38 | } 39 | 40 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 41 | #[repr(u8)] 42 | pub enum FileReleaseType { 43 | Release = 1, 44 | Beta = 2, 45 | Alpha = 3, 46 | } 47 | 48 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 49 | #[repr(u8)] 50 | pub enum FileStatus { 51 | Processing = 1, 52 | ChangesRequired = 2, 53 | UnderReview = 3, 54 | Approved = 4, 55 | Rejected = 5, 56 | MalwareDetected = 6, 57 | Deleted = 7, 58 | Archived = 8, 59 | Testing = 9, 60 | Released = 10, 61 | ReadyForReview = 11, 62 | Deprecated = 12, 63 | Baking = 13, 64 | AwaitingPublishing = 14, 65 | FailedPublishing = 15, 66 | Cooking = 16, 67 | Cooked = 17, 68 | UnderManualReview = 18, 69 | ScanningForMalware = 19, 70 | ProcessingFile = 20, 71 | PendingRelease = 21, 72 | ReadyForCooking = 22, 73 | PostProcessing = 23, 74 | } 75 | 76 | #[derive(Deserialize, Serialize, Debug, Clone)] 77 | #[serde(rename_all = "camelCase")] 78 | pub struct FileIndex { 79 | pub game_version: String, 80 | pub file_id: ID, 81 | pub filename: String, 82 | pub release_type: FileReleaseType, 83 | pub game_version_type_id: Option, 84 | pub mod_loader: Option, 85 | } 86 | 87 | #[derive(Deserialize, Serialize, Debug, Clone)] 88 | #[serde(rename_all = "camelCase")] 89 | pub struct FileHash { 90 | pub value: String, 91 | pub algo: HashAlgo, 92 | } 93 | 94 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 95 | #[repr(u8)] 96 | pub enum HashAlgo { 97 | Sha1 = 1, 98 | Md5 = 2, 99 | } 100 | 101 | #[derive(Deserialize, Serialize, Debug, Clone, Copy)] 102 | #[serde(rename_all = "camelCase")] 103 | pub struct FileDependency { 104 | pub mod_id: ID, 105 | pub relation_type: FileRelationType, 106 | } 107 | 108 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 109 | #[repr(u8)] 110 | pub enum FileRelationType { 111 | EmbeddedLibrary = 1, 112 | OptionalDependency = 2, 113 | RequiredDependency = 3, 114 | Tool = 4, 115 | Incompatible = 5, 116 | Include = 6, 117 | } 118 | 119 | #[derive(Deserialize, Serialize, Debug, Clone)] 120 | #[serde(rename_all = "camelCase")] 121 | pub struct FileModule { 122 | pub name: String, 123 | pub fingerprint: Number, 124 | } 125 | -------------------------------------------------------------------------------- /src/structures/fingerprint_structs.rs: -------------------------------------------------------------------------------- 1 | use super::{file_structs::File, *}; 2 | 3 | #[derive(Deserialize, Serialize, Debug, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct FingerprintMatches { 6 | pub is_cache_built: bool, 7 | /// The matches found 8 | pub exact_matches: Vec, 9 | /// The fingerprints of the matches found 10 | pub exact_fingerprints: Vec, 11 | pub partial_matches: Vec, 12 | pub partial_match_fingerprints: std::collections::HashMap>, 13 | /// The fingerprints that were requested 14 | pub installed_fingerprints: Vec, 15 | pub unmatched_fingerprints: Option>, 16 | } 17 | 18 | #[derive(Deserialize, Serialize, Debug, Clone)] 19 | #[serde(rename_all = "camelCase")] 20 | pub struct Match { 21 | /// The ID of the mod 22 | pub id: ID, 23 | pub file: File, 24 | /// The mod's latest files 25 | pub latest_files: Vec, 26 | } 27 | -------------------------------------------------------------------------------- /src/structures/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod common_structs; 2 | pub mod file_structs; 3 | pub mod fingerprint_structs; 4 | pub mod mod_structs; 5 | 6 | pub type UtcTime = chrono::DateTime; 7 | pub type Number = i64; 8 | pub type ID = i32; 9 | 10 | use common_structs::*; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_repr::{Deserialize_repr, Serialize_repr}; 13 | use url::Url; 14 | 15 | fn deserialise_optional_url<'de, D: serde::Deserializer<'de>>( 16 | de: D, 17 | ) -> Result, D::Error> { 18 | use serde::de::{Error, Unexpected}; 19 | use std::borrow::Cow; 20 | 21 | let intermediate = >>::deserialize(de)?; 22 | match intermediate.as_deref() { 23 | None | Some("") => Ok(None), 24 | Some(s) => Url::parse(s).map_or_else( 25 | |err| { 26 | Err(Error::invalid_value( 27 | Unexpected::Str(s), 28 | &err.to_string().as_str(), 29 | )) 30 | }, 31 | |ok| Ok(Some(ok)), 32 | ), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/structures/mod_structs.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | file_structs::{File, FileIndex}, 3 | *, 4 | }; 5 | 6 | #[derive(Deserialize, Serialize, Debug, Clone)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct Mod { 9 | pub id: ID, 10 | pub game_id: ID, 11 | pub name: String, 12 | /// The mod slug that would appear in the URL 13 | pub slug: String, 14 | /// Relevant links for the mod such as the issue tracker and wiki 15 | pub links: ModLinks, 16 | pub summary: String, 17 | pub status: ModStatus, 18 | pub download_count: Number, 19 | /// Whether the mod is included in the featured mods list 20 | pub is_featured: bool, 21 | /// The main category of the mod as it was chosen by the mod author 22 | pub primary_category_id: ID, 23 | /// List of categories that this mod is related to 24 | pub categories: Vec, 25 | /// The ID of the class this mod belongs to 26 | pub class_id: Option, 27 | pub authors: Vec, 28 | pub logo: Option, 29 | pub screenshots: Vec, 30 | pub main_file_id: ID, 31 | pub latest_files: Vec, 32 | /// List of file related details for the latest files of the mod 33 | pub latest_files_indexes: Vec, 34 | pub latest_early_access_files_indexes: Vec, 35 | pub date_created: UtcTime, 36 | pub date_modified: UtcTime, 37 | pub date_released: UtcTime, 38 | pub allow_mod_distribution: Option, 39 | pub game_popularity_rank: Number, 40 | /// Is the mod available for search. 41 | /// This can be false when a mod is experimental, in a deleted state, or has only alpha files. 42 | pub is_available: bool, 43 | pub thumbs_up_count: Number, 44 | pub rating: Option, 45 | } 46 | 47 | #[derive(Deserialize, Serialize, Debug, Clone)] 48 | #[serde(rename_all = "camelCase")] 49 | pub struct ModLinks { 50 | /// A link to the mod's CurseForge page 51 | pub website_url: Url, 52 | #[serde(deserialize_with = "deserialise_optional_url")] 53 | pub wiki_url: Option, 54 | #[serde(deserialize_with = "deserialise_optional_url")] 55 | pub issues_url: Option, 56 | #[serde(deserialize_with = "deserialise_optional_url")] 57 | pub source_url: Option, 58 | } 59 | 60 | #[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)] 61 | #[repr(u8)] 62 | pub enum ModStatus { 63 | New = 1, 64 | ChangesRequired = 2, 65 | UnderSoftReview = 3, 66 | Approved = 4, 67 | Rejected = 5, 68 | ChangesMade = 6, 69 | Inactive = 7, 70 | Abandoned = 8, 71 | Deleted = 9, 72 | UnderReview = 10, 73 | } 74 | 75 | #[derive(Deserialize, Serialize, Debug, Clone)] 76 | #[serde(rename_all = "camelCase")] 77 | pub struct ModAuthor { 78 | pub id: ID, 79 | pub name: String, 80 | pub url: Url, 81 | } 82 | 83 | #[derive(Deserialize, Serialize, Debug, Clone)] 84 | #[serde(rename_all = "camelCase")] 85 | pub struct ModAsset { 86 | pub id: ID, 87 | pub mod_id: ID, 88 | pub title: String, 89 | pub description: String, 90 | pub thumbnail_url: String, 91 | pub url: Url, 92 | } 93 | --------------------------------------------------------------------------------