Add Modrinth edit version API support

This commit is contained in:
CPTProgrammer
2026-07-18 12:33:11 +08:00
parent 619abfb0c5
commit 778ac54bfa
8 changed files with 330 additions and 2 deletions
+22
View File
@@ -60,4 +60,26 @@ export class ModrinthClient {
}
return res.json() as Promise<T>;
}
/**
* 发送 PATCH 请求,body 为 JSON。
* 成功响应为 204 No Content,无响应体,故返回 void。
*/
async patch(path: string, body: unknown): Promise<void> {
const res = await fetch(`${this.base}${path}`, {
method: "PATCH",
headers: {
...this.headers(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const reqBody = JSON.stringify(body);
const resBody = await readErrorBody(res);
throw new Error(
`Modrinth API PATCH ${path} failed: ${res.status} ${res.statusText}\n\n--Request--:\n${reqBody}\n\n--Response--:\n${resBody}`,
);
}
}
}
+1
View File
@@ -5,6 +5,7 @@ export {
getVersionByNumber,
getVersions,
createVersion,
updateVersion,
} from "./version";
export { getLoaders, getGameVersions } from "./tag";
export { getProject, getProjects } from "./project";
+62 -2
View File
@@ -3,8 +3,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModrinthClient } from "./client";
import { createVersion } from "./version";
import type { CreatableVersion } from "@/types";
import { createVersion, updateVersion } from "./version";
import {
EditableVersionSchema,
type CreatableVersion,
type EditableVersion,
} from "@/types";
describe("createVersion (dry-run)", () => {
const OLD_ENV = process.env;
@@ -71,3 +75,59 @@ describe("createVersion (dry-run)", () => {
).rejects.toThrow('file field "sources" declared in file_parts');
});
});
describe("updateVersion", () => {
const client = new ModrinthClient("fake-token", "test-agent");
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("发送 PATCH 请求到 /v2/version/{id}body 为 JSON", async () => {
const fields: EditableVersion = {
game_versions: ["1.21", "1.21.1"],
changelog: "updated changelog",
};
await updateVersion(client, "IIJJKKLL", fields);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.modrinth.com/v2/version/IIJJKKLL");
expect(init.method).toBe("PATCH");
expect(init.headers).toMatchObject({
Authorization: "fake-token",
"Content-Type": "application/json",
});
expect(JSON.parse(init.body as string)).toEqual(fields);
});
it("EditableVersionSchema 不注入 status 默认值", () => {
// Zod v4 中 .partial() 会触发 default,此处验证 schema 已规避该问题
expect(EditableVersionSchema.parse({})).toEqual({});
expect(EditableVersionSchema.parse({ game_versions: ["1.21"] })).toEqual({
game_versions: ["1.21"],
});
});
it("204 响应无 body,正常返回 void", async () => {
await expect(
updateVersion(client, "IIJJKKLL", { game_versions: ["1.21"] }),
).resolves.toBeUndefined();
});
it("非 2xx 响应抛出含状态码的错误", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: "not_found" }), { status: 404 }),
);
await expect(
updateVersion(client, "IIJJKKLL", { game_versions: ["1.21"] }),
).rejects.toThrow("404");
});
});
+21
View File
@@ -3,6 +3,7 @@ import {
VersionSchema,
type Version,
type CreatableVersion,
type EditableVersion,
} from "@/types";
/**
@@ -116,3 +117,23 @@ export async function createVersion(
const result = await client.post<unknown>("/version", form);
return VersionSchema.parse(result);
}
/**
* 修改已有版本的元数据(如兼容的 MC 版本范围、changelog 等)。
* PATCH /version/{id}
*
* 仅传入需要修改的字段即可,未传入的字段保持不变。
* 成功响应为 204 No Content,无返回值。
*
* @param client Modrinth 客户端实例
* @param versionId 版本 ID8 位 base62
* @param fields 已通过 Zod 校验的待修改字段(EditableVersion
*/
export async function updateVersion(
client: ModrinthClient,
versionId: string,
fields: EditableVersion,
): Promise<void> {
const path = `/version/${encodeURIComponent(versionId)}`;
await client.patch(path, fields);
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
import { BaseVersionSchema, FileTypeEnumSchema } from "./version";
// region EditableFileType — PATCH 时修改单个文件的类型
// OpenAPI EditableFileType.required: algorithm, hash, file_type
export const EditableFileTypeSchema = z.object({
/** 哈希算法(如 sha1、sha512 */
algorithm: z.string(),
/** 要修改的文件的哈希值 */
hash: z.string(),
/** 新的文件类型;null 表示清除类型标记 */
file_type: FileTypeEnumSchema.nullable(),
});
export type EditableFileType = z.infer<typeof EditableFileTypeSchema>;
// endregion
// region EditableVersion — PATCH /version/{id} 请求体
// OpenAPI EditableVersion = BaseVersion(全字段可选)+ primary_file / file_types
//
// 注意:不能直接 BaseVersionSchema.partial() —— Zod v4 中 .partial() 仍会
// 触发 status 字段的 .default("listed"),导致 PATCH 时误改版本状态。
// 因此显式将 status 覆盖为无 default 的可选字段。
export const EditableVersionSchema = BaseVersionSchema.partial()
.extend({
/** 版本状态;不传则不修改 */
status: z
.enum(["listed", "archived", "draft", "unlisted", "scheduled", "unknown"])
.optional(),
/** 新的主文件,格式为 [哈希算法, 哈希值],如 ["sha1", "aaaa..."] */
primary_file: z.tuple([z.string(), z.string()]).optional(),
/** 要修改文件类型的文件列表 */
file_types: z.array(EditableFileTypeSchema).optional(),
});
export type EditableVersion = z.infer<typeof EditableVersionSchema>;
// endregion
+7
View File
@@ -34,6 +34,13 @@ export {
type CreateVersionBody,
} from "./create-version";
export {
EditableFileTypeSchema,
EditableVersionSchema,
type EditableFileType,
type EditableVersion,
} from "./edit-version";
export {
ProjectLicenseSchema,
ProjectDonationURLSchema,