Indexium/indexium-backend/src/api/mods.rs

176 lines
8.3 KiB
Rust

use axum::{
extract::{Path, Query, State},
http::{header, HeaderValue, StatusCode},
response::IntoResponse,
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{db, AppState};
#[derive(Debug, Clone, Deserialize)]
pub struct ModSearchParams {
pub query: Option<String>,
#[serde(rename = "gameVersion")] pub game_version: Option<String>,
pub loader: Option<String>,
pub page: Option<i64>,
pub limit: Option<i64>,
pub sort: Option<ModSort>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModSort { Relevance, Newest, Popular, ActiveServers }
impl Default for ModSort { fn default() -> Self { Self::Relevance } }
fn sort_to_sql(s: ModSort) -> &'static str {
match s {
ModSort::Relevance => "mods.updated_at DESC, mods.slug ASC",
ModSort::Newest => "mods.updated_at DESC, mods.slug ASC",
ModSort::Popular => "mods.updated_at DESC, mods.slug ASC",
ModSort::ActiveServers => "mods.updated_at DESC, mods.slug ASC",
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ModListItem {
pub slug: String, pub name: String, pub summary: Option<String>,
pub author: String, pub icon_url: Option<String>,
pub game_versions: Vec<String>, pub loaders: Vec<String>,
pub latest_version: Option<String>, pub download_url: Option<String>,
pub updated_at: DateTime<Utc>,
pub stars_count: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Pagination { pub page: i64, pub limit: i64, pub total: i64, pub pages: i64 }
#[derive(Debug, Serialize, Deserialize)]
pub struct ModListResponse { pub data: Vec<ModListItem>, pub pagination: Pagination }
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorDto { pub login: String, pub avatar_url: Option<String> }
#[derive(Debug, Serialize, Deserialize)]
pub struct VersionDto {
pub version_number: String, pub game_versions: Vec<String>, pub loaders: Vec<String>,
pub download_url: String, pub file_sha256: String, pub file_size: Option<i64>,
pub published_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ModDetailResponse {
pub slug: String, pub name: String, pub summary: Option<String>,
pub description: Option<String>, pub github_repo: String,
pub author: AuthorDto, pub icon_url: Option<String>, pub verified: bool,
pub versions: Vec<VersionDto>, pub updated_at: DateTime<Utc>,
pub stars_count: i64,
}
pub async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchParams>) -> impl IntoResponse {
let page = p.page.unwrap_or(1);
let limit = p.limit.unwrap_or(20);
let sort = p.sort.unwrap_or_default();
if page < 1 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"validation_error","message":"page must be >=1"}))).into_response();
}
if !(1..=50).contains(&limit) {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"validation_error","message":"limit must be 1..50"}))).into_response();
}
let q = p.query.as_deref().filter(|s| !s.trim().is_empty());
let gv = p.game_version.as_deref().filter(|s| !s.trim().is_empty());
let loader = p.loader.as_deref().filter(|s| !s.trim().is_empty());
let offset = (page - 1) * limit;
let sort_sql = sort_to_sql(sort);
let total = match db::count_mods(&state.db, q, gv, loader).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e,"count_mods failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
}
};
let rows = match db::fetch_mods_page(&state.db, q, gv, loader, sort_sql, limit, offset).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e,"fetch_mods_page failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
}
};
let data = rows.into_iter().map(|r| ModListItem {
slug: r.slug, name: r.name, summary: r.summary, author: r.author, icon_url: r.icon_url,
game_versions: r.game_versions.unwrap_or_default(), loaders: r.loaders.unwrap_or_default(),
latest_version: r.latest_version, download_url: r.download_url, updated_at: r.updated_at,
stars_count: r.stars_count,
}).collect::<Vec<_>>();
let pages = if total == 0 { 0 } else { (total + limit - 1) / limit };
let body = ModListResponse { data, pagination: Pagination { page, limit, total, pages } };
let mut res = (StatusCode::OK, Json(body)).into_response();
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
res
}
pub async fn get_mod(State(state): State<AppState>, Path(slug): Path<String>) -> impl IntoResponse {
let m = match db::fetch_mod_by_slug(&state.db, &slug).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, slug=%slug,"fetch_mod_by_slug failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
}
};
let Some(m) = m else {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response();
};
let versions = match db::fetch_versions_for_mod(&state.db, m.id).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, slug=%slug,"fetch_versions_for_mod failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
}
};
let versions_dto = versions.into_iter().map(|v| VersionDto {
version_number: v.version_number, game_versions: v.game_versions, loaders: v.loaders,
download_url: v.download_url, file_sha256: v.file_sha256, file_size: None, published_at: v.published_at,
}).collect::<Vec<_>>();
let resp = ModDetailResponse {
slug: m.slug.clone(), name: m.name, summary: m.summary.clone(), description: m.summary,
github_repo: format!("{}/{}", m.owner, m.repo),
author: AuthorDto { login: m.owner, avatar_url: None },
icon_url: m.icon_url, verified: false, versions: versions_dto, updated_at: m.updated_at,
stars_count: m.stars_count,
};
let mut res = (StatusCode::OK, Json(resp)).into_response();
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
res
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn dto_serialize_smoke() {
let list = ModListResponse {
data: vec![ModListItem {
slug: "sodium-extra".into(), name: "Sodium Extra".into(),
summary: Some("Extra".into()), author: "flashy".into(),
icon_url: None, game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()],
latest_version: Some("1.2.3".into()), download_url: Some("https://example.com/mod.jar".into()),
updated_at: Utc::now(), stars_count: 5,
}],
pagination: Pagination { page: 1, limit: 20, total: 1, pages: 1 },
};
let j = serde_json::to_value(&list).unwrap();
assert_eq!(j["data"][0]["slug"], "sodium-extra");
assert_eq!(j["pagination"]["total"], 1);
let detail = ModDetailResponse {
slug: "sodium-extra".into(), name: "Sodium Extra".into(), summary: Some("s".into()),
description: Some("desc".into()), github_repo: "owner/repo".into(),
author: AuthorDto { login: "flashy".into(), avatar_url: None },
icon_url: None, verified: false,
versions: vec![VersionDto {
version_number: "1.2.3".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()],
download_url: "https://example.com/mod.jar".into(), file_sha256: "abc".into(), file_size: Some(123), published_at: Utc::now(),
}],
updated_at: Utc::now(), stars_count: 5,
};
let jd = serde_json::to_value(&detail).unwrap();
assert_eq!(jd["versions"][0]["version_number"], "1.2.3");
// sort enum deserialize
let p: ModSearchParams = serde_json::from_value(serde_json::json!({"sort":"newest","page":2})).unwrap();
assert_eq!(p.sort, Some(ModSort::Newest));
}
}