diff --git a/src/lib/modrinth/client.ts b/src/lib/modrinth/client.ts new file mode 100644 index 0000000..26ac2d7 --- /dev/null +++ b/src/lib/modrinth/client.ts @@ -0,0 +1,50 @@ +/** Modrinth API v2 的基础请求客户端,封装鉴权与 User-Agent。 */ +export class ModrinthClient { + private base = "https://api.modrinth.com/v2"; + private token: string; + private userAgent: string; + + constructor(token: string, userAgent?: string) { + this.token = token; + this.userAgent = userAgent ?? ""; + } + + /** 构造通用请求头(Authorization + User-Agent)。 */ + private headers(): Record { + return { + Authorization: this.token, + "User-Agent": this.userAgent, + }; + } + + /** 发送 GET 请求并解析 JSON 响应体。 */ + async get(path: string): Promise { + const res = await fetch(`${this.base}${path}`, { + headers: this.headers(), + }); + if (!res.ok) { + throw new Error( + `Modrinth API GET ${path} failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } + + /** + * 发送 POST 请求,body 为 FormData(multipart/form-data)。 + * 不手动设置 Content-Type,让 fetch 自动生成含 boundary 的头。 + */ + async post(path: string, body: FormData): Promise { + const res = await fetch(`${this.base}${path}`, { + method: "POST", + headers: this.headers(), + body, + }); + if (!res.ok) { + throw new Error( + `Modrinth API POST ${path} failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } +} diff --git a/src/lib/modrinth/index.ts b/src/lib/modrinth/index.ts new file mode 100644 index 0000000..9c9e962 --- /dev/null +++ b/src/lib/modrinth/index.ts @@ -0,0 +1,9 @@ +export { ModrinthClient } from "./client"; +export { + listVersions, + getVersion, + getVersionByNumber, + getVersions, + createVersion, +} from "./version"; +export { getLoaders, getGameVersions } from "./tag"; diff --git a/src/lib/modrinth/tag.ts b/src/lib/modrinth/tag.ts new file mode 100644 index 0000000..4333977 --- /dev/null +++ b/src/lib/modrinth/tag.ts @@ -0,0 +1,29 @@ +import { ModrinthClient } from "./client"; +import { + LoaderTagSchema, + GameVersionTagSchema, + type LoaderTag, + type GameVersionTag, +} from "@/types"; + +/** + * 获取 Modrinth 支持的全部模组加载器列表。 + * GET /tag/loader + */ +export async function getLoaders( + client: ModrinthClient, +): Promise { + const data = await client.get("/tag/loader"); + return LoaderTagSchema.array().parse(data); +} + +/** + * 获取 Modrinth 支持的全部 Minecraft 版本列表。 + * GET /tag/game_version + */ +export async function getGameVersions( + client: ModrinthClient, +): Promise { + const data = await client.get("/tag/game_version"); + return GameVersionTagSchema.array().parse(data); +} diff --git a/src/lib/modrinth/version.ts b/src/lib/modrinth/version.ts new file mode 100644 index 0000000..bac0196 --- /dev/null +++ b/src/lib/modrinth/version.ts @@ -0,0 +1,121 @@ +import { ModrinthClient } from "./client"; +import { + VersionSchema, + type Version, + type CreatableVersion, +} from "@/types"; + +/** + * 列出指定项目的全部版本。 + * GET /project/{id|slug}/version + */ +export async function listVersions( + client: ModrinthClient, + projectId: string, + options?: { + loaders?: string[]; + game_versions?: string[]; + featured?: boolean; + include_changelog?: boolean; + }, +): Promise { + const params = new URLSearchParams(); + + if (options?.loaders) { + params.set("loaders", JSON.stringify(options.loaders)); + } + if (options?.game_versions) { + params.set("game_versions", JSON.stringify(options.game_versions)); + } + if (options?.featured !== undefined) { + params.set("featured", String(options.featured)); + } + // 强烈建议不带 changelog 以减小响应体 + params.set( + "include_changelog", + String(options?.include_changelog ?? false), + ); + + const qs = params.toString(); + const path = `/project/${encodeURIComponent(projectId)}/version${qs ? `?${qs}` : ""}`; + const data = await client.get(path); + return VersionSchema.array().parse(data); +} + +/** + * 获取单个版本的完整信息。 + * GET /version/{id} + */ +export async function getVersion( + client: ModrinthClient, + versionId: string, +): Promise { + const path = `/version/${encodeURIComponent(versionId)}`; + const data = await client.get(path); + return VersionSchema.parse(data); +} + +/** + * 通过项目 ID + 版本号获取版本。 + * GET /project/{id|slug}/version/{id|number} + */ +export async function getVersionByNumber( + client: ModrinthClient, + projectId: string, + versionNumber: string, +): Promise { + const path = `/project/${encodeURIComponent(projectId)}/version/${encodeURIComponent(versionNumber)}`; + const data = await client.get(path); + return VersionSchema.parse(data); +} + +/** + * 批量获取多个版本信息。 + * GET /versions?ids=["...","..."] + */ +export async function getVersions( + client: ModrinthClient, + versionIds: string[], +): Promise { + const params = new URLSearchParams(); + params.set("ids", JSON.stringify(versionIds)); + const path = `/versions?${params.toString()}`; + const data = await client.get(path); + return VersionSchema.array().parse(data); +} + +/** + * 创建一个新版本(含文件上传)。 + * POST /version + * + * @param client Modrinth 客户端实例 + * @param data 已通过 Zod 校验的版本元数据(CreatableVersion) + * @param files 文件映射:key 为 multipart 字段名(需与 data.file_parts 一致),value 为文件 Blob + */ +export async function createVersion( + client: ModrinthClient, + data: CreatableVersion, + files: Record, +): Promise { + const form = new FormData(); + + // 元数据 JSON 字符串 + form.append("data", JSON.stringify(data)); + + // 逐一添加文件 + for (const fieldName of data.file_parts) { + const blob = files[fieldName]; + if (!blob) { + throw new Error( + `createVersion: file field "${fieldName}" declared in file_parts but not provided in files`, + ); + } + // 从 Blob 推断文件名;若无则使用字段名作为回退 + const filename = + blob instanceof File ? blob.name : `${fieldName}.jar`; + form.append(fieldName, blob, filename); + } + + const result = await client.post("/version", form); + return VersionSchema.parse(result); +}