Modrinth Dependencies (#8)

* temp Modrinth Dependencies

* Fix final issues

* Got to love git sometimes

* Made test case for modrinth-utils

* That's why testing is important kids

* Included dependencies are still dependencies

* Naming

* Moved to Modrinth API v2

Co-authored-by: Kir_Antipov <kp.antipov@gmail.com>
This commit is contained in:
Fx Morin 2022-06-05 10:28:41 -04:00 committed by GitHub
parent 347040cd63
commit bf3f3c7c01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 151 additions and 87 deletions

64
package-lock.json generated
View file

@ -2476,6 +2476,16 @@
"integrity": "sha512-V3orv+ggDsWVHP99K3JlwtH20R7J4IhI1Kksgc+64q5VxgfRkQG8Ws3MFm/FZOKDYGy9feGFlZ70/HpCNe9QaA==",
"dev": true
},
"@types/node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==",
"dev": true,
"requires": {
"@types/node": "*",
"form-data": "^3.0.0"
}
},
"@types/prettier": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz",
@ -2855,6 +2865,11 @@
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"babel-jest": {
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.0.tgz",
@ -3170,6 +3185,14 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3251,6 +3274,11 @@
"object-keys": "^1.1.1"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"deprecation": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
@ -3697,13 +3725,14 @@
"integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
"dev": true
},
"formdata-node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.3.2.tgz",
"integrity": "sha512-k7lYJyzDOSL6h917favP8j1L0/wNyylzU+x+1w4p5haGVHNlP58dbpdJhiCUsDbWsa9HwEtLp89obQgXl2e0qg==",
"form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
"requires": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.1"
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"fs.realpath": {
@ -5578,6 +5607,19 @@
"picomatch": "^2.3.1"
}
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.52.0"
}
},
"mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@ -5605,11 +5647,6 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
@ -6365,11 +6402,6 @@
"makeerror": "1.0.12"
}
},
"web-streams-polyfill": {
"version": "4.0.0-beta.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz",
"integrity": "sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -30,6 +30,7 @@
"@babel/preset-env": "^7.18.2",
"@babel/preset-typescript": "^7.17.12",
"@types/node": "^17.0.36",
"@types/node-fetch": "^2.6.1",
"@types/yazl": "^2.4.2",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
@ -44,7 +45,7 @@
"@actions/core": "^1.8.2",
"@actions/github": "^5.0.3",
"fast-glob": "^3.2.11",
"formdata-node": "^4.3.2",
"form-data": "^3.0.1",
"node-fetch": "^2.6.7",
"node-stream-zip": "^1.15.0",
"toml": "^3.0.0"

View file

@ -1,22 +1,40 @@
import { createVersion } from "../../utils/modrinth-utils";
import { createVersion, getProject } from "../../utils/modrinth-utils";
import { File } from "../../utils/file";
import ModPublisher from "../mod-publisher";
import PublisherTarget from "../publisher-target";
import Dependency from "../../metadata/dependency";
import DependencyKind from "../../metadata/dependency-kind";
const modrinthDependencyKinds = new Map([
[DependencyKind.Depends, "required"],
[DependencyKind.Recommends, "optional"],
[DependencyKind.Suggests, "optional"],
[DependencyKind.Includes, "optional"],
[DependencyKind.Breaks, "incompatible"],
]);
export default class ModrinthPublisher extends ModPublisher {
public get target(): PublisherTarget {
return PublisherTarget.Modrinth;
}
protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[]): Promise<void> {
protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[], dependencies: Dependency[]): Promise<void> {
const projects = (await Promise.all(dependencies
.filter((x, _, self) => (x.kind !== DependencyKind.Suggests && x.kind !== DependencyKind.Includes) || !self.find(y => y.id === x.id && y.kind !== DependencyKind.Suggests && y.kind !== DependencyKind.Includes))
.map(async x => ({
project_id: (await getProject(x.getProjectSlug(this.target))).id,
dependency_type: modrinthDependencyKinds.get(x.kind)
}))))
.filter(x => x.project_id && x.dependency_type);
const data = {
version_title: name || version,
name: name || version,
version_number: version,
version_body: changelog,
release_channel: channel,
changelog,
game_versions: gameVersions,
version_type: channel,
loaders,
featured: true,
dependencies: projects
};
await createVersion(id, data, files, token);
}

View file

@ -1,6 +1,5 @@
import fetch from "node-fetch";
import { FormData } from "formdata-node";
import { fileFromPath } from "formdata-node/file-from-path";
import FormData from "form-data";
import { File } from "./file";
import { findVersionByName } from "./minecraft-utils";
import SoftError from "./soft-error";
@ -111,11 +110,12 @@ export async function uploadFile(id: string, data: Record<string, any>, file: Fi
}
const form = new FormData();
form.append("file", await fileFromPath(file.path), file.name);
form.append("file", file.getStream(), file.name);
form.append("metadata", JSON.stringify(data));
const response = await fetch(`${baseUrl}/projects/${id}/upload-file?token=${token}`, {
method: "POST",
headers: form.getHeaders(),
body: <any>form
});

View file

@ -11,6 +11,10 @@ export class File {
Object.freeze(this);
}
public getStream(): fs.ReadStream {
return fs.createReadStream(this.path);
}
public async getBuffer(): Promise<Buffer> {
return new Promise((resolve, reject) => {
fs.readFile(this.path, (error, data) => {

View file

@ -1,7 +0,0 @@
import crypto from "crypto";
import fs from "fs";
export function computeHash(path: string, algorithm: string): Promise<crypto.Hash> {
const hash = crypto.createHash(algorithm);
return new Promise(resolve => fs.createReadStream(path).on("data", data => hash.update(data)).on("end", () => resolve(hash)));
}

View file

@ -1,15 +1,26 @@
import { FormData } from "formdata-node";
import { fileFromPath } from "formdata-node/file-from-path";
import FormData from "form-data";
import fetch from "node-fetch";
import { File } from "./file";
import { computeHash } from "./hash-utils";
import SoftError from "./soft-error";
export async function createVersion(modId: string, data: Record<string, any>, files: File[], token: string): Promise<string> {
const baseUrl = "https://api.modrinth.com/v2";
interface ModrinthProject {
id: string;
slug: string;
}
interface ModrinthVersion {
id: string;
}
export async function createVersion(modId: string, data: Record<string, any>, files: File[], token: string): Promise<ModrinthVersion> {
data = {
featured: true,
dependencies: [],
...data,
mod_id: modId,
project_id: modId,
primary_file: files.length ? "0" : undefined,
file_parts: files.map((_, i) => i.toString())
};
@ -17,12 +28,14 @@ export async function createVersion(modId: string, data: Record<string, any>, fi
form.append("data", JSON.stringify(data));
for (let i = 0; i < files.length; ++i) {
const file = files[i];
form.append(i.toString(), await fileFromPath(file.path), file.name);
form.append(i.toString(), file.getStream(), file.name);
}
const response = await fetch("https://api.modrinth.com/api/v1/version", {
const response = await fetch(`${baseUrl}/version`, {
method: "POST",
headers: { Authorization: token },
headers: form.getHeaders({
Authorization: token,
}),
body: <any>form
});
@ -35,27 +48,19 @@ export async function createVersion(modId: string, data: Record<string, any>, fi
throw new SoftError(isServerError, `Failed to upload file: ${response.status} (${errorText})`);
}
const versionId = (<{ id: string }>await response.json()).id;
const primaryFile = files[0];
if (primaryFile) {
await makeFilePrimary(versionId, primaryFile.path, token);
}
return versionId;
return await response.json();
}
export async function makeFilePrimary(versionId: string, filePath: string, token: string): Promise<boolean> {
const algorithm = "sha1";
const hash = (await computeHash(filePath, algorithm)).digest("hex");
const response = await fetch(`https://api.modrinth.com/api/v1/version/${versionId}`, {
method: "PATCH",
headers: {
"Authorization": token,
"Content-Type": "application/json"
},
body: JSON.stringify({
primary_file: [algorithm, hash]
})
});
return response.ok;
export async function getProject(idOrSlug: string): Promise<ModrinthProject> {
const response = await fetch(`${baseUrl}/project/${idOrSlug}`);
if (response.ok) {
return await response.json();
}
if (response.status === 404) {
return null;
}
const isServerError = response.status >= 500;
throw new SoftError(isServerError, `${response.status} (${response.statusText})`);
}

View file

@ -1,26 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "@jest/globals";
import { computeHash } from "../src/utils/hash-utils";
import fs from "fs";
describe("computeHash", () => {
beforeAll(() => new Promise(resolve => {
fs.writeFile("hello-world.txt", "Hello world!", resolve);
}));
afterAll(() => new Promise(resolve => fs.unlink("hello-world.txt", resolve)));
test("sha1 is supported", async () => {
const algorithm = "sha1";
expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("d3486ae9136e7856bc42212385ea797094475802");
});
test("sha256 is supported", async () => {
const algorithm = "sha256";
expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51a");
});
test("sha512 is supported", async () => {
const algorithm = "sha512";
expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("f6cde2a0f819314cdde55fc227d8d7dae3d28cc556222a0a8ad66d91ccad4aad6094f517a2182360c9aacf6a3dc323162cb6fd8cdffedb0fe038f55e85ffb5b6");
});
});

View file

@ -0,0 +1,37 @@
import { jest, describe, test, expect } from "@jest/globals";
import { getProject } from "../src/utils/modrinth-utils";
describe("getProject", () => {
test("returned versions have expected ids", async () => {
jest.setTimeout(15000);
const projects = {
"sodium": "AANobbMI",
"fabric-api": "P7dR8mSH",
"sync-fabric": "OrJTMhHF",
"nether-chest": "okOUGirG",
"ebe": "OVuFYfre",
};
for (const [slug, id] of Object.entries(projects)) {
const project = await getProject(slug);
expect(project).toHaveProperty("id", id);
}
});
test("the method returns null if project with the given slug does not exist", async () => {
jest.setTimeout(15000);
const nonExistentProjects = [
"Na-11",
"api-fabric",
"sync-forge",
"ever-chest",
"beb",
"i-swear-to-god-if-someone-registers-these-mods"
];
for (const slug of nonExistentProjects) {
const project = await getProject(slug);
expect(project).toBeNull();
}
});
});