diff --git a/docs/index.md b/docs/index.md index 6721dc8..532650e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -92,6 +92,7 @@ src/types/ | 文件 | 说明 | |------|------| | `upload.md` | 新建版本 API(`POST /version`) | +| `edit.md` | 修改版本 API(`PATCH /version/{id}`) | | `get.md` | 获取版本信息 API(列出项目版本、获取单个版本等) | | `get-meta.md` | 元数据查询 API(加载器列表、游戏版本列表等) | diff --git a/docs/modrinth/edit.md b/docs/modrinth/edit.md new file mode 100644 index 0000000..849cce3 --- /dev/null +++ b/docs/modrinth/edit.md @@ -0,0 +1,181 @@ +# Modrinth 修改版本 API 使用文档 + +本文档描述如何通过 Modrinth API 修改一个**已有版本**的元数据,例如更新兼容的 Minecraft 版本范围(`game_versions`)、changelog、加载器列表等,无需重新上传文件。 + +--- + +## 基本信息 + +| 项目 | 内容 | +|------|------| +| **端点** | `PATCH /version/{id}` | +| **生产环境** | `https://api.modrinth.com/v2/version/{id}` | +| **测试环境** | `https://staging-api.modrinth.com/v2/version/{id}` | +| **Content-Type** | `application/json` | +| **认证** | 必需 — Personal Access Token (PAT) | +| **所需 Scope** | `VERSION_WRITE` | +| **成功响应码** | `204 No Content`(无响应体) | + +> 创建版本用 `POST /version`(见 [upload.md](./upload.md));修改版本不需要重新上传文件,只要传 JSON 即可。 + +--- + +## 路径参数 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `id` | 版本 ID(8 位 base62 字符串) | `IIJJKKLL` | + +> 版本 ID 可通过 `GET /project/{id|slug}/version` 列表获取(见 [get.md](./get.md))。 + +--- + +## 请求体(EditableVersion) + +请求体为 JSON,**所有字段均为可选,只需传入要修改的字段**,未传入的字段保持不变。 + +### 继承自 BaseVersion 的字段 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `name` | `string` | 版本名称 | `"Version 1.0.0"` | +| `version_number` | `string` | 版本号 | `"1.0.0"` | +| `changelog` | `string \| null` | 更新日志(Markdown/纯文本) | `"修复了若干 Bug"` | +| `dependencies` | `object[]` | 依赖列表(整体替换),结构同创建版本 | 见 [upload.md](./upload.md#依赖结构) | +| `game_versions` | `string[]` | **支持的 Minecraft 版本列表(整体替换)** | `["1.21", "1.21.1"]` | +| `version_type` | `string` | 发布渠道:`release`、`beta`、`alpha` | `"release"` | +| `loaders` | `string[]` | 支持的加载器列表(整体替换) | `["fabric"]` | +| `featured` | `boolean` | 是否为精选版本 | `false` | +| `status` | `string` | 版本状态:`listed`、`archived`、`draft`、`unlisted`、`scheduled`、`unknown` | `"listed"` | +| `requested_status` | `string \| null` | 请求的状态(用于审核流程):`listed`、`archived`、`draft`、`unlisted` | `"listed"` | + +> **注意:** `game_versions`、`loaders`、`dependencies` 都是**整体替换**语义,传入的值会完全覆盖原值,而不是增量添加。修改兼容范围时应先读取版本当前的 `game_versions`,在此基础上增删后再整体提交。 + +### EditableVersion 独有字段 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `primary_file` | `[string, string]` | 新的主文件,二元组 `[哈希算法, 哈希值]` | `["sha1", "aaaabbbb..."]` | +| `file_types` | `object[]` | 要修改文件类型的文件列表(见下方) | 见下方 | + +#### `file_types` 数组项(EditableFileType) + +通过文件哈希定位版本中的某个文件并修改其类型标记: + +| 字段 | 类型 | 必填 | 说明 | 示例 | +|------|------|------|------|------| +| `algorithm` | `string` | ✅ | 哈希算法(如 `sha1`、`sha512`) | `"sha1"` | +| `hash` | `string` | ✅ | 要修改的文件的哈希值 | `"aaaabbbb..."` | +| `file_type` | `string \| null` | ✅ | 新的文件类型;`null` 表示清除类型标记。枚举值见 [upload.md](./upload.md) 的 `FileTypeEnum` 表 | `"sources-jar"` | + +> 文件的 `sha1` / `sha512` 哈希可从版本详情的 `files[].hashes` 字段获取(见 [get.md](./get.md))。 + +--- + +## 请求示例 + +### 修改兼容的 MC 版本范围 + +```bash +curl -X PATCH "https://api.modrinth.com/v2/version/IIJJKKLL" \ + -H "Authorization: YOUR_PAT_TOKEN" \ + -H "User-Agent: your_username/your_project/1.0.0" \ + -H "Content-Type: application/json" \ + -d '{ + "game_versions": ["1.21", "1.21.1", "1.21.4"] + }' +``` + +### Node.js (fetch) 示例 + +```js +const TOKEN = "YOUR_PAT_TOKEN"; +const VERSION_ID = "IIJJKKLL"; + +const response = await fetch( + `https://api.modrinth.com/v2/version/${VERSION_ID}`, + { + method: "PATCH", + headers: { + Authorization: TOKEN, + "User-Agent": "your_username/your_project/1.0.0", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + game_versions: ["1.21", "1.21.1", "1.21.4"], + }), + }, +); + +// 成功时 response.status === 204,无响应体 +console.log(response.status); // 204 +``` + +### 同时修改 changelog 和发布渠道 + +```js +await fetch(`https://api.modrinth.com/v2/version/${VERSION_ID}`, { + method: "PATCH", + headers: { + Authorization: TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + changelog: "## 更新内容\n\n- 修复了某某 Bug", + version_type: "beta", + }), +}); +``` + +--- + +## 响应 + +成功时返回 **`204 No Content`,无响应体**。修改后的完整版本信息可通过 `GET /version/{id}` 再次获取确认。 + +--- + +## 错误码 + +| 状态码 | 说明 | +|--------|------| +| `204` | 成功 | +| `401` | Token 无效、未提供 Token,或 Token 不包含 `VERSION_WRITE` 权限范围 | +| `404` | 版本不存在,或无权访问该版本 | + +--- + +## 注意事项 + +### 1. 增量修改语义 + +PATCH 只更新传入的字段,未传入的字段保持服务器上的原值不变。这与 `POST /version`(全量创建)不同。 + +### 2. 数组字段是整体替换 + +`game_versions`、`loaders`、`dependencies` 传入后会**完全覆盖**原值。若只想"添加一个 MC 版本",正确做法是: + +1. `GET /version/{id}` 读取当前 `game_versions`; +2. 在数组中追加新版本号; +3. PATCH 提交完整的新数组。 + +### 3. 不要传递未修改的 status + +客户端序列化请求体时,未显式设置的字段不应出现在 JSON 中。尤其注意不要让 `status` 被默认值(如 `"listed"`)填充,否则会意外改变版本状态(例如把 `draft` 变成 `listed`)。 + +### 4. 与"追加文件"的区别 + +本接口只修改元数据,不能上传新文件。向已有版本追加文件应使用 `POST /version/{id}/file`(见 [upload.md](./upload.md) 注意事项第 8 条,当前项目尚未封装该接口)。 + +### 5. Rate Limit + +与 Modrinth 其他接口相同:每 IP 每分钟最多 **300** 个请求。 + +--- + +## 参考 + +- [Modrinth API 文档](https://docs.modrinth.com) +- [OpenAPI 规范文件](openapi.yml)(`modifyVersion` 操作 / `EditableVersion` schema) +- [新建版本 API 文档](upload.md) +- [获取版本信息 API 文档](get.md) diff --git a/src/lib/modrinth/client.ts b/src/lib/modrinth/client.ts index 8d5bd02..41f0199 100644 --- a/src/lib/modrinth/client.ts +++ b/src/lib/modrinth/client.ts @@ -60,4 +60,26 @@ export class ModrinthClient { } return res.json() as Promise; } + + /** + * 发送 PATCH 请求,body 为 JSON。 + * 成功响应为 204 No Content,无响应体,故返回 void。 + */ + async patch(path: string, body: unknown): Promise { + 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}`, + ); + } + } } diff --git a/src/lib/modrinth/index.ts b/src/lib/modrinth/index.ts index ab9452d..b95eb30 100644 --- a/src/lib/modrinth/index.ts +++ b/src/lib/modrinth/index.ts @@ -5,6 +5,7 @@ export { getVersionByNumber, getVersions, createVersion, + updateVersion, } from "./version"; export { getLoaders, getGameVersions } from "./tag"; export { getProject, getProjects } from "./project"; diff --git a/src/lib/modrinth/version.test.ts b/src/lib/modrinth/version.test.ts index 4916205..ba658d4 100644 --- a/src/lib/modrinth/version.test.ts +++ b/src/lib/modrinth/version.test.ts @@ -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; + + 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"); + }); +}); diff --git a/src/lib/modrinth/version.ts b/src/lib/modrinth/version.ts index 6550d1a..dff619b 100644 --- a/src/lib/modrinth/version.ts +++ b/src/lib/modrinth/version.ts @@ -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("/version", form); return VersionSchema.parse(result); } + +/** + * 修改已有版本的元数据(如兼容的 MC 版本范围、changelog 等)。 + * PATCH /version/{id} + * + * 仅传入需要修改的字段即可,未传入的字段保持不变。 + * 成功响应为 204 No Content,无返回值。 + * + * @param client Modrinth 客户端实例 + * @param versionId 版本 ID(8 位 base62) + * @param fields 已通过 Zod 校验的待修改字段(EditableVersion) + */ +export async function updateVersion( + client: ModrinthClient, + versionId: string, + fields: EditableVersion, +): Promise { + const path = `/version/${encodeURIComponent(versionId)}`; + await client.patch(path, fields); +} diff --git a/src/types/modrinth/edit-version.ts b/src/types/modrinth/edit-version.ts new file mode 100644 index 0000000..419cbe7 --- /dev/null +++ b/src/types/modrinth/edit-version.ts @@ -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; +// 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; +// endregion diff --git a/src/types/modrinth/index.ts b/src/types/modrinth/index.ts index 7ed83c9..1ee8492 100644 --- a/src/types/modrinth/index.ts +++ b/src/types/modrinth/index.ts @@ -34,6 +34,13 @@ export { type CreateVersionBody, } from "./create-version"; +export { + EditableFileTypeSchema, + EditableVersionSchema, + type EditableFileType, + type EditableVersion, +} from "./edit-version"; + export { ProjectLicenseSchema, ProjectDonationURLSchema,