pakker/src/platform/modrinth.rs
NotAShelf 0cc72e9916
platform: add mockito HTTP tests to modrinth
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I880c11195559fcfb9701e945a10fe87b6a6a6964
2026-02-19 00:22:47 +03:00

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(&params.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());
}
}