Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I880c11195559fcfb9701e945a10fe87b6a6a6964
415 lines
11 KiB
Rust
415 lines
11 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use async_trait::async_trait;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::traits::PlatformClient;
|
|
use crate::{
|
|
error::{PakkerError, Result},
|
|
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
|
|
utils::generate_pakku_id,
|
|
};
|
|
|
|
const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2";
|
|
|
|
pub struct ModrinthPlatform {
|
|
client: Client,
|
|
}
|
|
|
|
impl ModrinthPlatform {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
client: Client::new(),
|
|
}
|
|
}
|
|
|
|
async fn request_project_url(&self, url: &str) -> Result<Project> {
|
|
let response = self.client.get(url).send().await?;
|
|
if !response.status().is_success() {
|
|
return Err(PakkerError::ProjectNotFound(url.to_string()));
|
|
}
|
|
let mr_project: ModrinthProject = response.json().await?;
|
|
Ok(self.convert_project(mr_project))
|
|
}
|
|
|
|
async fn request_project_files_url(
|
|
&self,
|
|
url: &str,
|
|
) -> Result<Vec<ProjectFile>> {
|
|
let response = self.client.get(url).send().await?;
|
|
if !response.status().is_success() {
|
|
return Err(PakkerError::ProjectNotFound(url.to_string()));
|
|
}
|
|
let mr_versions: Vec<ModrinthVersion> = response.json().await?;
|
|
let project_id = url
|
|
.split('/')
|
|
.nth(4)
|
|
.ok_or_else(|| {
|
|
PakkerError::InvalidResponse(
|
|
"Cannot parse project ID from URL".to_string(),
|
|
)
|
|
})?
|
|
.to_string();
|
|
Ok(
|
|
mr_versions
|
|
.into_iter()
|
|
.map(|v| self.convert_version(v, &project_id))
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
async fn lookup_by_hash_url(&self, url: &str) -> Result<Option<Project>> {
|
|
let response = self.client.get(url).send().await?;
|
|
if response.status().as_u16() == 404 {
|
|
return Ok(None);
|
|
}
|
|
if !response.status().is_success() {
|
|
return Err(PakkerError::PlatformApiError(format!(
|
|
"Modrinth API error: {}",
|
|
response.status()
|
|
)));
|
|
}
|
|
let version_data: serde_json::Value = response.json().await?;
|
|
let project_id = version_data["project_id"].as_str().ok_or_else(|| {
|
|
PakkerError::InvalidResponse("Missing project_id".to_string())
|
|
})?;
|
|
self
|
|
.request_project_with_files(project_id, &[], &[])
|
|
.await
|
|
.map(Some)
|
|
}
|
|
|
|
fn map_project_type(type_str: &str) -> ProjectType {
|
|
match type_str {
|
|
"mod" => ProjectType::Mod,
|
|
"resourcepack" => ProjectType::ResourcePack,
|
|
"datapack" => ProjectType::DataPack,
|
|
"shader" => ProjectType::Shader,
|
|
_ => ProjectType::Mod,
|
|
}
|
|
}
|
|
|
|
const fn map_side(client: bool, server: bool) -> ProjectSide {
|
|
match (client, server) {
|
|
(true, true) => ProjectSide::Both,
|
|
(true, false) => ProjectSide::Client,
|
|
(false, true) => ProjectSide::Server,
|
|
_ => ProjectSide::Both,
|
|
}
|
|
}
|
|
|
|
fn map_release_type(type_str: &str) -> ReleaseType {
|
|
match type_str {
|
|
"release" => ReleaseType::Release,
|
|
"beta" => ReleaseType::Beta,
|
|
"alpha" => ReleaseType::Alpha,
|
|
_ => ReleaseType::Release,
|
|
}
|
|
}
|
|
|
|
fn convert_project(&self, mr_project: ModrinthProject) -> Project {
|
|
let pakku_id = generate_pakku_id();
|
|
let mut project = Project::new(
|
|
pakku_id,
|
|
Self::map_project_type(&mr_project.project_type),
|
|
Self::map_side(
|
|
mr_project.client_side != "unsupported",
|
|
mr_project.server_side != "unsupported",
|
|
),
|
|
);
|
|
|
|
project.add_platform(
|
|
"modrinth".to_string(),
|
|
mr_project.id.clone(),
|
|
mr_project.slug.clone(),
|
|
mr_project.title,
|
|
);
|
|
|
|
project
|
|
}
|
|
|
|
fn convert_version(
|
|
&self,
|
|
mr_version: ModrinthVersion,
|
|
project_id: &str,
|
|
) -> ProjectFile {
|
|
let mut hashes = HashMap::new();
|
|
|
|
// Get primary file
|
|
let primary_file = mr_version
|
|
.files
|
|
.iter()
|
|
.find(|f| f.primary)
|
|
.or_else(|| mr_version.files.first())
|
|
.expect("Version must have at least one file");
|
|
|
|
for (algo, hash) in &primary_file.hashes {
|
|
hashes.insert(algo.clone(), hash.clone());
|
|
}
|
|
|
|
ProjectFile {
|
|
file_type: "mod".to_string(),
|
|
file_name: primary_file.filename.clone(),
|
|
mc_versions: mr_version.game_versions.clone(),
|
|
loaders: mr_version.loaders.clone(),
|
|
release_type: Self::map_release_type(&mr_version.version_type),
|
|
url: primary_file.url.clone(),
|
|
id: mr_version.id.clone(),
|
|
parent_id: project_id.to_string(),
|
|
hashes,
|
|
required_dependencies: mr_version
|
|
.dependencies
|
|
.iter()
|
|
.filter(|d| d.dependency_type == "required")
|
|
.filter_map(|d| d.project_id.clone())
|
|
.collect(),
|
|
size: primary_file.size,
|
|
date_published: mr_version.date_published.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PlatformClient for ModrinthPlatform {
|
|
async fn request_project(
|
|
&self,
|
|
identifier: &str,
|
|
_mc_versions: &[String],
|
|
_loaders: &[String],
|
|
) -> Result<Project> {
|
|
let url = format!("{MODRINTH_API_BASE}/project/{identifier}");
|
|
self.request_project_url(&url).await
|
|
}
|
|
|
|
async fn request_project_files(
|
|
&self,
|
|
project_id: &str,
|
|
mc_versions: &[String],
|
|
loaders: &[String],
|
|
) -> Result<Vec<ProjectFile>> {
|
|
let mut url = format!("{MODRINTH_API_BASE}/project/{project_id}/version");
|
|
|
|
// Add query parameters
|
|
let mut params = vec![];
|
|
if !mc_versions.is_empty() {
|
|
params.push(format!(
|
|
"game_versions=[{}]",
|
|
mc_versions
|
|
.iter()
|
|
.map(|v| format!("\"{v}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
));
|
|
}
|
|
if !loaders.is_empty() {
|
|
params.push(format!(
|
|
"loaders=[{}]",
|
|
loaders
|
|
.iter()
|
|
.map(|l| format!("\"{l}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
));
|
|
}
|
|
|
|
if !params.is_empty() {
|
|
url.push('?');
|
|
url.push_str(¶ms.join("&"));
|
|
}
|
|
|
|
self.request_project_files_url(&url).await
|
|
}
|
|
|
|
async fn request_project_with_files(
|
|
&self,
|
|
identifier: &str,
|
|
mc_versions: &[String],
|
|
loaders: &[String],
|
|
) -> Result<Project> {
|
|
let mut project = self
|
|
.request_project(identifier, mc_versions, loaders)
|
|
.await?;
|
|
let project_id = project
|
|
.get_platform_id("modrinth")
|
|
.ok_or_else(|| {
|
|
PakkerError::InternalError("Missing modrinth ID".to_string())
|
|
})?
|
|
.clone();
|
|
|
|
let files = self
|
|
.request_project_files(&project_id, mc_versions, loaders)
|
|
.await?;
|
|
project.files = files;
|
|
|
|
Ok(project)
|
|
}
|
|
|
|
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
|
// Modrinth uses SHA-1 hash for file lookups
|
|
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
|
|
self.lookup_by_hash_url(&url).await
|
|
}
|
|
}
|
|
|
|
// Modrinth API models
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct ModrinthProject {
|
|
id: String,
|
|
slug: String,
|
|
title: String,
|
|
#[serde(rename = "project_type")]
|
|
project_type: String,
|
|
client_side: String,
|
|
server_side: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct ModrinthVersion {
|
|
id: String,
|
|
project_id: String,
|
|
name: String,
|
|
version_number: String,
|
|
game_versions: Vec<String>,
|
|
version_type: String,
|
|
loaders: Vec<String>,
|
|
date_published: String,
|
|
files: Vec<ModrinthFile>,
|
|
dependencies: Vec<ModrinthDependency>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct ModrinthFile {
|
|
hashes: HashMap<String, String>,
|
|
url: String,
|
|
filename: String,
|
|
primary: bool,
|
|
size: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct ModrinthDependency {
|
|
project_id: Option<String>,
|
|
dependency_type: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use reqwest::Client;
|
|
|
|
use super::*;
|
|
|
|
impl ModrinthPlatform {
|
|
fn with_client(client: Client) -> Self {
|
|
Self { client }
|
|
}
|
|
}
|
|
|
|
async fn create_platform_with_mock()
|
|
-> (ModrinthPlatform, mockito::ServerGuard) {
|
|
let server = mockito::Server::new_async().await;
|
|
let client = Client::new();
|
|
let platform = ModrinthPlatform::with_client(client);
|
|
(platform, server)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_request_project_success() {
|
|
let (platform, mut server) = create_platform_with_mock().await;
|
|
let url = format!("{}/project/test-mod", server.url());
|
|
|
|
let _mock = server
|
|
.mock("GET", "/project/test-mod")
|
|
.with_status(200)
|
|
.with_header("content-type", "application/json")
|
|
.with_body(
|
|
r#"{
|
|
"id": "abc123",
|
|
"slug": "test-mod",
|
|
"title": "Test Mod",
|
|
"project_type": "mod",
|
|
"client_side": "required",
|
|
"server_side": "required"
|
|
}"#,
|
|
)
|
|
.create();
|
|
|
|
let result = platform.request_project_url(&url).await;
|
|
|
|
assert!(result.is_ok());
|
|
let project = result.unwrap();
|
|
assert!(project.get_platform_id("modrinth").is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_request_project_not_found() {
|
|
let (platform, mut server) = create_platform_with_mock().await;
|
|
let url = format!("{}/project/nonexistent", server.url());
|
|
|
|
let _mock = server
|
|
.mock("GET", "/project/nonexistent")
|
|
.with_status(404)
|
|
.create();
|
|
|
|
let result = platform.request_project_url(&url).await;
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_request_project_files() {
|
|
let (platform, mut server) = create_platform_with_mock().await;
|
|
let url = format!("{}/project/abc123/version", server.url());
|
|
|
|
let _mock = server
|
|
.mock("GET", "/project/abc123/version")
|
|
.with_status(200)
|
|
.with_header("content-type", "application/json")
|
|
.with_body(
|
|
r#"[
|
|
{
|
|
"id": "v1",
|
|
"project_id": "abc123",
|
|
"name": "Test Mod v1.0.0",
|
|
"version_number": "1.0.0",
|
|
"game_versions": ["1.20.1"],
|
|
"version_type": "release",
|
|
"loaders": ["fabric"],
|
|
"date_published": "2024-01-01T00:00:00Z",
|
|
"files": [{
|
|
"hashes": {"sha1": "abc123def456"},
|
|
"url": "https://example.com/mod.jar",
|
|
"filename": "test-mod-1.0.0.jar",
|
|
"primary": true,
|
|
"size": 1024
|
|
}],
|
|
"dependencies": []
|
|
}
|
|
]"#,
|
|
)
|
|
.create();
|
|
|
|
let result = platform.request_project_files_url(&url).await;
|
|
|
|
assert!(result.is_ok());
|
|
let files = result.unwrap();
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(files[0].file_name, "test-mod-1.0.0.jar");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_lookup_by_hash_not_found() {
|
|
let (platform, mut server) = create_platform_with_mock().await;
|
|
let url = format!("{}/version_file/unknownhash123", server.url());
|
|
|
|
let _mock = server
|
|
.mock("GET", "/version_file/unknownhash123")
|
|
.with_status(404)
|
|
.create();
|
|
|
|
let result = platform.lookup_by_hash_url(&url).await;
|
|
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_none());
|
|
}
|
|
}
|