diff --git a/crates/spuz_get/Cargo.toml b/crates/spuz_get/Cargo.toml index 842b0f0..72ca950 100644 --- a/crates/spuz_get/Cargo.toml +++ b/crates/spuz_get/Cargo.toml @@ -5,15 +5,32 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -readme.workspace = true +description = "Pack of apis to get any versions of the game, even modded, such as fabric, quilt, forge, etc" +readme = "readme.md" +keywords = ["minecraft", "launcher", "downlaoder", "client"] +categories = [] [dependencies] -arc-swap = { version = "1" } -reqwest = { version = "0", features = ["json"] } -serde = { workspace = true } -serde_json = { workspace = true } +spuz_piston = { workspace = true } -[lints] -workspace = true +thiserror = { version = "1" } +reqwest = { version = "0.12", features = ["json", "stream"], optional = true } +url = { version = "2" } +async-trait = { version = "0.1" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +tokio = { version = "1", features = ["fs"] } +futures-lite = { version = "2" } +futures-util = { version = "0.3", features = ["io"], optional = true } +async-compat = { version = "0.2" } + +[dev-dependencies] +pollster = { version = "0.3" } [features] +default = ["reqwest", "vanilla"] +reqwest = ["dep:reqwest", "dep:futures-util"] +vanilla = [] + +[lints] +workspace = true diff --git a/crates/spuz_get/readme.md b/crates/spuz_get/readme.md new file mode 100644 index 0000000..5c786da --- /dev/null +++ b/crates/spuz_get/readme.md @@ -0,0 +1,26 @@ +# spuz_get *by [coppebars](https://github.com/coppebars)* +All you need to install minecraft + +# Example +```rust +use reqwest::Client; +use spuz_get::{vanilla::package, Error}; +use spuz_piston::Manifest; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let client = Client::default(); + + let package = + package::(&client, "d585c8e981e58326237746ca1253dea15c9e4aaa", "24w21b") + .await + .map_err(Error::from_fetch)? + .json() + .await?; + + println!("{package:#?}"); + + Ok(()) +} + +``` diff --git a/crates/spuz_get/src/client.rs b/crates/spuz_get/src/client.rs new file mode 100644 index 0000000..149df86 --- /dev/null +++ b/crates/spuz_get/src/client.rs @@ -0,0 +1,96 @@ +use async_trait::async_trait; +use futures_lite::AsyncRead; +use serde::de::DeserializeOwned; +use thiserror::Error; +use url::Url; + +pub(crate) type BoxedAsyncRead = Box; + +#[async_trait] +pub trait Client { + type Error: std::error::Error + 'static; + + /// Sends a GET request and deserializes the result as json + /// # Example + /// ```no_run + /// # use std::error::Error; + /// use spuz_get::Client; + /// use spuz_piston::Manifest; + /// # type Result = std::result::Result>; + /// + /// async fn get_manifest(client: &C) -> Result + /// where + /// ::Error: Error + 'static + /// { + /// let json = client.get_json("https://piston-meta.mojang.com/v1/packages/d585c8e981e58326237746ca1253dea15c9e4aaa/24w21b.json".parse()?).await?; + /// Ok(json) + /// } + /// ``` + async fn get_json(&self, url: Url) -> Result + where + T: DeserializeOwned; + + /// Sends a GET request and returns [AsyncRead] stream + /// + /// # Example + /// ```no_run + /// # use std::error::Error; + /// use async_compat::CompatExt; + /// use futures_lite::io; + /// use tokio::fs::File; + /// use spuz_get::Client; + /// use spuz_piston::Manifest; + /// # type Result = std::result::Result>; + /// + /// async fn download_jar(client: &C) -> Result<()> + /// where + /// ::Error: Error + 'static + /// { + /// let mut stream = client.get_stream("https://piston-data.mojang.com/v1/objects/9d5b45173a0123720bae94afc8a35d742e559d5a/client.jar".parse()?).await?; + /// let mut file = File::open("./client.jar").await?; + /// io::copy(&mut stream, &mut file.compat_mut()).await?; + /// Ok(()) + /// } + /// ``` + async fn get_stream(&self, url: Url) -> Result; +} + +#[cfg(feature = "reqwest")] +#[async_trait] +impl Client for reqwest::Client { + type Error = reqwest::Error; + + #[inline] + async fn get_json(&self, url: Url) -> Result + where + T: DeserializeOwned, + { + self.get(url).send().await?.json().await + } + + #[inline] + async fn get_stream(&self, url: Url) -> Result { + use futures_util::TryStreamExt; + + #[inline] + fn map_err(err: reqwest::Error) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::Other, err) + } + + let bytes_stream = self.get(url).send().await?.bytes_stream().map_err(map_err); + + Ok(Box::new(bytes_stream.into_async_read())) + } +} + +#[derive(Debug, Error)] +pub enum FetchError { + #[error("Fetch failed: {0}")] + Client(#[source] C::Error), + #[error("Invalid url: {0}")] + ParseUrl( + #[from] + #[source] + url::ParseError, + ), +} diff --git a/crates/spuz_get/src/err.rs b/crates/spuz_get/src/err.rs new file mode 100644 index 0000000..5371fa0 --- /dev/null +++ b/crates/spuz_get/src/err.rs @@ -0,0 +1,29 @@ +use std::fmt::Debug; + +use thiserror::Error; + +use crate::{ + ext::{FsExtLoadError, FsExtSaveError}, + json_resource::{JsonResourceParseError, JsonResourceSaveError}, + Client, FetchError, +}; + +#[derive(Debug, Error)] +pub enum Error { + #[error("FetchError: {0}")] + Fetch(#[source] Box), + #[error(transparent)] + JsonResourceSave(#[from] JsonResourceSaveError), + #[error(transparent)] + JsonResourceParse(#[from] JsonResourceParseError), + #[error(transparent)] + FsExtSave(#[from] FsExtSaveError), + #[error(transparent)] + FsExtLoda(#[from] FsExtLoadError), +} + +impl Error { + pub fn from_fetch(err: FetchError) -> Self { + Self::Fetch(Box::new(err)) + } +} diff --git a/crates/spuz_get/src/ext.rs b/crates/spuz_get/src/ext.rs new file mode 100644 index 0000000..b085fc7 --- /dev/null +++ b/crates/spuz_get/src/ext.rs @@ -0,0 +1,86 @@ +use std::{io, path::Path}; + +use async_trait::async_trait; +use spuz_piston::{AssetIndex, Manifest, RuntimeManifest}; +use thiserror::Error; +use tokio::{fs, io::AsyncWriteExt}; + +#[derive(Debug, Error)] +#[error("{0}")] +pub enum FsExtLoadError { + ReadFile( + #[from] + #[source] + io::Error, + ), + Deserialize( + #[from] + #[source] + serde_json::Error, + ), +} + +#[derive(Debug, Error)] +#[error("{0}")] +pub enum FsExtSaveError { + CreateFile(#[source] io::Error), + Copy(#[source] io::Error), +} + +/// Allows you to [save](Self::save) or [load](Self::load) documents from the +/// file system +#[async_trait] +pub trait FsExt: Sized { + /// Saves the document locally + /// # Example + /// ```no_run + /// # use std::error::Error; + /// # use pollster::FutureExt; + /// use spuz_piston::Manifest; + /// use spuz_get::FsExt; + /// + /// # async move { + /// let manifest = Manifest::load("./1.20.6.json").await?; + /// manifest.save("./1.20.6-new.json").await?; + /// # Result::<(), Box>::Ok(()) + /// # }.block_on(); + /// ``` + async fn save(&self, path: impl AsRef + Send) -> Result<(), FsExtSaveError>; + /// Loads the document from the fs + /// # Example + /// ```no_run + /// # use std::error::Error; + /// # use pollster::FutureExt; + /// use spuz_piston::Manifest; + /// use spuz_get::FsExt; + /// + /// # async move { + /// let manifest = Manifest::load("./1.20.6.json").await?; + /// manifest.save("./1.20.6-new.json").await?; + /// # Result::<(), Box>::Ok(()) + /// # }.block_on(); + /// ``` + async fn load(path: impl AsRef + Send) -> Result; +} + +macro_rules! save_ext { + ($what:ident) => { + #[async_trait] + impl FsExt for $what { + async fn save(&self, path: impl AsRef + Send) -> Result<(), FsExtSaveError> { + let mut file = fs::File::create(path).await.map_err(FsExtSaveError::CreateFile)?; + file.write_all(self.to_string().as_bytes()).await.map_err(FsExtSaveError::Copy)?; + Ok(()) + } + + async fn load(path: impl AsRef + Send) -> Result { + let content = fs::read_to_string(path).await?; + content.parse().map_err(Into::into) + } + } + }; +} + +save_ext!(Manifest); +save_ext!(AssetIndex); +save_ext!(RuntimeManifest); diff --git a/crates/spuz_get/src/json_resource.rs b/crates/spuz_get/src/json_resource.rs new file mode 100644 index 0000000..0150fa9 --- /dev/null +++ b/crates/spuz_get/src/json_resource.rs @@ -0,0 +1,85 @@ +use std::{ + fmt::{Debug, Formatter}, + marker::PhantomData, + path::Path, +}; + +use async_compat::CompatExt; +use futures_lite::{io, AsyncRead}; +use futures_util::AsyncReadExt; +use serde::de::DeserializeOwned; +use thiserror::Error; +use tokio::fs::File; + +/// You can get `JsonResource` from some api calls. If you just need to get the +/// structure from the json response, use the [json](JsonResource::json) method. +/// Sometimes you may need to save the result to a file, then use the +/// [save](JsonResource::save) method to avoid unnecessary parsing. +/// +/// It is recommended that you perform one of the actions immediately, as +/// prolonged inactivity may result in a timeout. +/// +/// # Example +/// ```no_run +/// # use pollster::FutureExt;/// +/// # async move { +/// let json_resource = todo!(); +/// +/// let result = json_resource.json().await?; +/// // Or +/// json_resource.save("./local.json").await?; +/// # Result::<(), spuz_get::Error>::Ok(()) +/// # }.block_on() +/// ``` +pub struct JsonResource { + pub(crate) stream: R, + pub(crate) json: PhantomData, +} + +impl Debug for JsonResource { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + "JsonResource { ... }".fmt(f) + } +} + +impl JsonResource +where + R: AsyncRead + Unpin, + D: DeserializeOwned, +{ + /// Reads the underlying stream to end and parses as [D] + pub async fn json(&mut self) -> Result { + let mut content = String::new(); + self.stream.read_to_string(&mut content).await?; + Ok(serde_json::from_str(&content)?) + } + + /// Copies the underlying stream to the file + pub async fn save(&mut self, path: impl AsRef) -> Result<(), JsonResourceSaveError> { + let mut file = File::create(path).await.map_err(JsonResourceSaveError::CreateFile)?; + io::copy(&mut self.stream, file.compat_mut()).await.map_err(JsonResourceSaveError::Copy)?; + Ok(()) + } +} + +#[derive(Debug, Error)] +#[error("JsonResource parse error: {0}")] +pub enum JsonResourceParseError { + Read( + #[source] + #[from] + io::Error, + ), + Parse( + #[source] + #[from] + serde_json::Error, + ), +} + +#[derive(Debug, Error)] +#[error("JsonResource save error: {0}")] +pub enum JsonResourceSaveError { + CreateFile(#[source] io::Error), + Copy(#[source] io::Error), +} diff --git a/crates/spuz_get/src/lib.rs b/crates/spuz_get/src/lib.rs index 8b13789..00f142b 100644 --- a/crates/spuz_get/src/lib.rs +++ b/crates/spuz_get/src/lib.rs @@ -1 +1,13 @@ +pub mod client; +mod err; +pub mod ext; +pub mod json_resource; +#[cfg(feature = "vanilla")] +pub mod vanilla; +pub use crate::{ + client::{Client, FetchError}, + err::Error, + ext::FsExt, + json_resource::JsonResource, +}; diff --git a/crates/spuz_get/src/vanilla/mod.rs b/crates/spuz_get/src/vanilla/mod.rs new file mode 100644 index 0000000..9b1ba68 --- /dev/null +++ b/crates/spuz_get/src/vanilla/mod.rs @@ -0,0 +1,40 @@ +use std::marker::PhantomData; + +use spuz_piston::list::Versions; +use url::Url; + +use crate::{client::BoxedAsyncRead, Client, FetchError, JsonResource}; + +/// Lists all versions of minecraft over time +pub async fn list(client: &C) -> Result> { + let url = Url::parse("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")?; + client.get_json(url).await.map_err(FetchError::Client) +} + +/// Requests json package from `https://piston-meta.mojang.com/v1/packages` by `hash` and `id` +pub async fn package( + client: &C, + hash: &str, + id: &str, +) -> Result, FetchError> { + let url: Url = format!("https://piston-meta.mojang.com/v1/packages/{hash}/{id}.json").parse()?; + let stream = client.get_stream(url).await.map_err(FetchError::Client)?; + + Ok(JsonResource { stream, json: PhantomData }) +} + +/// Requests binary object from `https://piston-meta.mojang.com/v1/objects` by `hash` and `id` +pub async fn object(client: &C, hash: &str, id: &str) -> Result> { + let url: Url = format!("https://piston-meta.mojang.com/v1/objects/{hash}/{id}").parse()?; + let stream = client.get_stream(url).await.map_err(FetchError::Client)?; + + Ok(stream) +} + +/// Requests game resource (aka assets) from `https://resources.download.minecraft.net/` +pub async fn resource(client: &C, hash: &str) -> Result> { + let url: Url = format!("https://resources.download.minecraft.net/{h2}/{hash}", h2 = &hash[..2]).parse()?; + let stream = client.get_stream(url).await.map_err(FetchError::Client)?; + + Ok(stream) +}