core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。
core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。
core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。
presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。
app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。
测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
232 lines
8.0 KiB
Rust
232 lines
8.0 KiB
Rust
//! Modrinth v2 的四个端点(**只有一个站点,所以不做「源分派」那一层**)。
|
||
//!
|
||
//! | 方法 | 端点 | 拿什么 |
|
||
//! |------|------|--------|
|
||
//! | [`Modrinth::search`] | `GET /v2/search` | 一页命中项(分页游标在 [`SearchQuery`] 里) |
|
||
//! | [`Modrinth::project`] | `GET /v2/project/{id or slug}` | 项目详情(含长文与链接) |
|
||
//! | [`Modrinth::versions`] | `GET /v2/project/{id}/version` | 版本列表(可按版本/加载器过滤) |
|
||
//! | [`Modrinth::version_by_hash`] | `GET /v2/version_file/{hash}` | 某个文件的所属版本(**补全路径用的那条**) |
|
||
//!
|
||
//! # 端点拼装与解析都分离出来
|
||
//!
|
||
//! `*_url` 是纯函数(单测直接比字符串),`search/project/...` 只做「取文本 → 解析」。
|
||
//! 于是录制 JSON 的离线夹具不需要任何 HTTP 桩就能覆盖全部分支。
|
||
//!
|
||
//! # 为什么按摘要查是 GET
|
||
//!
|
||
//! Modrinth 的批量接口是 `POST /v2/version_files`(请求体是一个 JSON 对象)。引擎的
|
||
//! `DownloadRequest` 没有请求体字段,为它单开一条直发 `client()` 的路径就得自己写退避与限流
|
||
//! (presenter 里为 CF 的 header 已经付过这笔账)。补全路径一次只看一两个摘要,逐条 GET 更划算。
|
||
//!
|
||
//! # 百分号编码
|
||
//!
|
||
//! 只为 query 串写一个最小实现(`A-Za-z0-9-._~` 不编码),**不引第三方 crate**:
|
||
//! `facets` 与 `game_versions` 都是 JSON 文本,方括号引号逗号全要编码,否则站点会当成
|
||
//! 空过滤条件静默忽略——那会让「筛选条说 Fabric、结果里有 Forge」这种谎话成立。
|
||
|
||
use super::model::{
|
||
parse_project, parse_search, parse_version, parse_versions, ProjectDetail, SearchPage,
|
||
SearchQuery, Version, VersionQuery,
|
||
};
|
||
use super::transport::{OnlineError, Transport};
|
||
|
||
/// Modrinth API 的根(匿名可用,无需 key)。
|
||
pub const MODRINTH_API: &str = "https://api.modrinth.com/v2";
|
||
|
||
/// 按哪个摘要查文件。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum HashAlgorithm {
|
||
/// SHA-1(mrpack 的 `files[].hashes.sha1`)
|
||
#[default]
|
||
Sha1,
|
||
/// SHA-512(mrpack 的必填摘要)
|
||
Sha512,
|
||
}
|
||
|
||
impl HashAlgorithm {
|
||
/// 站点 `algorithm` 参数值。
|
||
#[must_use]
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Sha1 => "sha1",
|
||
Self::Sha512 => "sha512",
|
||
}
|
||
}
|
||
|
||
/// 十六进制长度(格式自检用;长度不对就别发请求)。
|
||
#[must_use]
|
||
pub const fn hex_len(self) -> usize {
|
||
match self {
|
||
Self::Sha1 => 40,
|
||
Self::Sha512 => 128,
|
||
}
|
||
}
|
||
|
||
/// 摘要是否**形状**合法(小写归一 + 十六进制 + 长度)。
|
||
#[must_use]
|
||
pub fn accepts(self, hex: &str) -> bool {
|
||
hex.len() == self.hex_len() && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
}
|
||
}
|
||
|
||
/// Modrinth 客户端(只持有 [`Transport`],不持有客户端/连接池)。
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct Modrinth<'a, T: Transport + ?Sized> {
|
||
transport: &'a T,
|
||
}
|
||
|
||
impl<'a, T: Transport + ?Sized> Modrinth<'a, T> {
|
||
/// 绑定传输层。
|
||
#[must_use]
|
||
pub const fn new(transport: &'a T) -> Self {
|
||
Self { transport }
|
||
}
|
||
|
||
/// 检索的 URL(纯函数,单测直接钉)。
|
||
#[must_use]
|
||
pub fn search_url(&self, query: &SearchQuery) -> String {
|
||
let query = query.normalized();
|
||
let mut url = format!(
|
||
"{MODRINTH_API}/search?limit={}&offset={}&index={}",
|
||
query.limit,
|
||
query.offset,
|
||
query.index.as_str()
|
||
);
|
||
let facets = query.facets();
|
||
if facets != "[]" {
|
||
url.push_str(&format!("&facets={}", encode(&facets)));
|
||
}
|
||
if !query.text.is_empty() {
|
||
url.push_str(&format!("&query={}", encode(&query.text)));
|
||
}
|
||
url
|
||
}
|
||
|
||
/// 项目详情的 URL。
|
||
#[must_use]
|
||
pub fn project_url(&self, id_or_slug: &str) -> String {
|
||
format!("{MODRINTH_API}/project/{}", encode(id_or_slug))
|
||
}
|
||
|
||
/// 版本列表的 URL。
|
||
#[must_use]
|
||
pub fn versions_url(&self, query: &VersionQuery) -> String {
|
||
let mut url = format!("{MODRINTH_API}/project/{}/version", encode(&query.project));
|
||
let mut first = true;
|
||
if let Some(version) = query
|
||
.game_version
|
||
.as_deref()
|
||
.filter(|item| !item.is_empty())
|
||
{
|
||
url.push_str(&format!(
|
||
"{}game_versions={}",
|
||
separator(&mut first),
|
||
encode(&format!("[\"{version}\"]"))
|
||
));
|
||
}
|
||
if let Some(loader) = query.loader.as_deref().filter(|item| !item.is_empty()) {
|
||
url.push_str(&format!(
|
||
"{}loaders={}",
|
||
separator(&mut first),
|
||
encode(&format!("[\"{loader}\"]"))
|
||
));
|
||
}
|
||
url
|
||
}
|
||
|
||
/// 按文件摘要查所属版本的 URL。
|
||
#[must_use]
|
||
pub fn version_file_url(&self, algorithm: HashAlgorithm, hex: &str) -> String {
|
||
format!(
|
||
"{MODRINTH_API}/version_file/{}?algorithm={}",
|
||
encode(&hex.to_ascii_lowercase()),
|
||
algorithm.as_str()
|
||
)
|
||
}
|
||
|
||
/// 检索一页。
|
||
///
|
||
/// # Errors
|
||
/// 传输层失败(见 [`OnlineErrorKind`])或响应形状不符。
|
||
pub async fn search(&self, query: &SearchQuery) -> Result<SearchPage, OnlineError> {
|
||
let normalized = query.normalized();
|
||
let url = self.search_url(&normalized);
|
||
let body = self.transport.get_text(&url).await?;
|
||
let mut page = parse_search(&body, normalized.offset, normalized.limit)?;
|
||
// 站点没回显时用请求值补齐,分页游标才不会因为缺字段而卡死。
|
||
if page.limit == 0 {
|
||
page.limit = normalized.limit;
|
||
}
|
||
Ok(page)
|
||
}
|
||
|
||
/// 项目详情。
|
||
///
|
||
/// # Errors
|
||
/// 传输层失败或响应形状不符。
|
||
pub async fn project(&self, id_or_slug: &str) -> Result<ProjectDetail, OnlineError> {
|
||
let url = self.project_url(id_or_slug);
|
||
let body = self.transport.get_text(&url).await?;
|
||
parse_project(&body)
|
||
}
|
||
|
||
/// 版本列表(站点的顺序 = 最新在前)。
|
||
///
|
||
/// # Errors
|
||
/// 传输层失败或响应形状不符。
|
||
pub async fn versions(&self, query: &VersionQuery) -> Result<Vec<Version>, OnlineError> {
|
||
let url = self.versions_url(query);
|
||
let body = self.transport.get_text(&url).await?;
|
||
parse_versions(&body)
|
||
}
|
||
|
||
/// 按文件摘要查所属版本。
|
||
///
|
||
/// # Errors
|
||
/// 摘要形状不对时**不发请求**([`OnlineErrorKind::Unsupported`]);站点 404 是
|
||
/// [`OnlineErrorKind::NotFound`](= 这个摘要不在 Modrinth 上,补全要如实记「补不出」)。
|
||
pub async fn version_by_hash(
|
||
&self,
|
||
algorithm: HashAlgorithm,
|
||
hex: &str,
|
||
) -> Result<Version, OnlineError> {
|
||
let hex = hex.trim();
|
||
if !algorithm.accepts(hex) {
|
||
return Err(OnlineError::unsupported(format!(
|
||
"摘要不是合法的 {}:长度 {},要求 {}",
|
||
algorithm.as_str(),
|
||
hex.len(),
|
||
algorithm.hex_len()
|
||
)));
|
||
}
|
||
let url = self.version_file_url(algorithm, hex);
|
||
let body = self.transport.get_text(&url).await?;
|
||
parse_version(&body)
|
||
}
|
||
}
|
||
|
||
/// 第一个参数用 `?`,后续用 `&`。
|
||
fn separator(first: &mut bool) -> &'static str {
|
||
if *first {
|
||
*first = false;
|
||
"?"
|
||
} else {
|
||
"&"
|
||
}
|
||
}
|
||
|
||
/// 最小百分号编码(query 串用;`A-Za-z0-9-._~` 原样,其余按字节编码)。
|
||
#[must_use]
|
||
pub fn encode(raw: &str) -> String {
|
||
let mut out = String::with_capacity(raw.len());
|
||
for byte in raw.bytes() {
|
||
match byte {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||
out.push(char::from(byte));
|
||
}
|
||
_ => out.push_str(&format!("%{byte:02X}")),
|
||
}
|
||
}
|
||
out
|
||
}
|