Refactored metadata readers

- `ZippedLoaderMetadataReader` was removed
 - Metadata readers now throw instead of returning `undefined`
This commit is contained in:
Kir_Antipov 2024-01-15 15:49:16 +03:00
parent cf10a77ae0
commit 22bd9471da
11 changed files with 55 additions and 284 deletions

View file

@ -1,15 +1,18 @@
import { ZippedTextLoaderMetadataReader } from "@/loaders/zipped-loader-metadata-reader"; import { PathLike } from "node:fs";
import { readAllZippedText } from "@/utils/io/file-info";
import { LoaderMetadataReader } from "../loader-metadata-reader";
import { FabricMetadata } from "./fabric-metadata"; import { FabricMetadata } from "./fabric-metadata";
import { FABRIC_MOD_JSON, RawFabricMetadata } from "./raw-fabric-metadata"; import { FABRIC_MOD_JSON } from "./raw-fabric-metadata";
/** /**
* A metadata reader that is able to read Fabric mod metadata from a zipped file. * A metadata reader that is able to read Fabric mod metadata from a zipped file.
*/ */
export class FabricMetadataReader extends ZippedTextLoaderMetadataReader<FabricMetadata, RawFabricMetadata> { export class FabricMetadataReader implements LoaderMetadataReader<FabricMetadata> {
/** /**
* Constructs a new {@link FabricMetadataReader} instance. * @inheritdoc
*/ */
constructor() { async readMetadataFile(path: PathLike): Promise<FabricMetadata> {
super(FABRIC_MOD_JSON, FabricMetadata.from, JSON.parse); const metadataText = await readAllZippedText(path, FABRIC_MOD_JSON);
return FabricMetadata.from(JSON.parse(metadataText));
} }
} }

View file

@ -1,16 +1,19 @@
import { PathLike } from "node:fs";
import { parse as parseToml } from "toml"; import { parse as parseToml } from "toml";
import { ZippedTextLoaderMetadataReader } from "@/loaders/zipped-loader-metadata-reader"; import { readAllZippedText } from "@/utils/io/file-info";
import { MODS_TOML, RawForgeMetadata } from "./raw-forge-metadata"; import { LoaderMetadataReader } from "../loader-metadata-reader";
import { ForgeMetadata } from "./forge-metadata"; import { ForgeMetadata } from "./forge-metadata";
import { MODS_TOML } from "./raw-forge-metadata";
/** /**
* A metadata reader that is able to read Forge mod metadata from a zipped file. * A metadata reader that is able to read Forge mod metadata from a zipped file.
*/ */
export class ForgeMetadataReader extends ZippedTextLoaderMetadataReader<ForgeMetadata, RawForgeMetadata> { export class ForgeMetadataReader implements LoaderMetadataReader<ForgeMetadata> {
/** /**
* Constructs a new {@link ForgeMetadataReader} instance. * @inheritdoc
*/ */
constructor() { async readMetadataFile(path: PathLike): Promise<ForgeMetadata> {
super(MODS_TOML, ForgeMetadata.from, parseToml); const metadataText = await readAllZippedText(path, MODS_TOML);
return ForgeMetadata.from(parseToml(metadataText));
} }
} }

View file

@ -17,9 +17,11 @@ export interface LoaderMetadataReader<T extends LoaderMetadata = LoaderMetadata>
* *
* @param path - The path to the metadata file. * @param path - The path to the metadata file.
* *
* @returns The metadata object, or `undefined` if the file cannot be read. * @returns The metadata object.
*
* @throws {Error} - If the file cannot be read.
*/ */
readMetadataFile(path: PathLike): Promise<T | undefined>; readMetadataFile(path: PathLike): Promise<T>;
} }
/** /**
@ -40,7 +42,7 @@ export function combineLoaderMetadataReaders(readers: Iterable<LoaderMetadataRea
return metadata; return metadata;
} }
} }
return undefined; throw new Error(`Unable to read metadata from the file located at: '${path}'`);
}; };
return { readMetadataFile }; return { readMetadataFile };

View file

@ -1,15 +1,18 @@
import { ZippedTextLoaderMetadataReader } from "@/loaders/zipped-loader-metadata-reader"; import { PathLike } from "node:fs";
import { readAllZippedText } from "@/utils/io/file-info";
import { LoaderMetadataReader } from "../loader-metadata-reader";
import { QuiltMetadata } from "./quilt-metadata"; import { QuiltMetadata } from "./quilt-metadata";
import { QUILT_MOD_JSON, RawQuiltMetadata } from "./raw-quilt-metadata"; import { QUILT_MOD_JSON } from "./raw-quilt-metadata";
/** /**
* A metadata reader that is able to read Quilt mod metadata from a zipped file. * A metadata reader that is able to read Quilt mod metadata from a zipped file.
*/ */
export class QuiltMetadataReader extends ZippedTextLoaderMetadataReader<QuiltMetadata, RawQuiltMetadata> { export class QuiltMetadataReader implements LoaderMetadataReader<QuiltMetadata> {
/** /**
* Constructs a new {@link QuiltMetadataReader} instance. * @inheritdoc
*/ */
constructor() { async readMetadataFile(path: PathLike): Promise<QuiltMetadata> {
super(QUILT_MOD_JSON, QuiltMetadata.from, JSON.parse); const metadataText = await readAllZippedText(path, QUILT_MOD_JSON);
return QuiltMetadata.from(JSON.parse(metadataText));
} }
} }

View file

@ -1,115 +0,0 @@
import { Awaitable } from "@/utils/types";
import { StreamZipAsync, async as ZipArchive } from "node-stream-zip";
import { PathLike } from "node:fs";
import { LoaderMetadata } from "./loader-metadata";
import { LoaderMetadataReader } from "./loader-metadata-reader";
/**
* Provides a base for reading metadata from zipped files for various loaders.
*
* @template TMetadata - Represents the processed metadata object.
* @template TRawMetadata - Represents the raw metadata object to be transformed.
*/
export abstract class ZippedLoaderMetadataReader<TMetadata extends LoaderMetadata, TRawMetadata> implements LoaderMetadataReader<TMetadata> {
/**
* The name of the entry inside the zipped file to read.
*/
private readonly _entry: string;
/**
* Constructs a new {@link ZippedLoaderMetadataReader} instance.
*
* @param entry - The name of the entry inside the zipped file to read.
*/
protected constructor(entry: string) {
this._entry = entry;
}
/**
* Reads the metadata file from a zipped file at the given path.
*
* @param path - The path to the zipped file.
*
* @returns The metadata object, or `undefined` if the zipped file cannot be read.
*/
async readMetadataFile(path: PathLike): Promise<TMetadata | undefined> {
let zip = undefined as StreamZipAsync;
try {
zip = new ZipArchive({ file: path as string });
const buffer = await zip.entryData(this._entry);
if (!buffer) {
return undefined;
}
const rawMetadata = await this.readRawMetadata(buffer);
return await this.createMetadata(rawMetadata);
} catch {
return undefined;
} finally {
await zip?.close().catch(() => undefined);
}
}
/**
* Reads the raw metadata from a buffer.
*
* @param buffer - The buffer containing the raw metadata.
*
* @returns The raw metadata object.
*/
protected abstract readRawMetadata(buffer: Buffer): Promise<TRawMetadata>;
/**
* Creates a metadata object from the raw metadata.
*
* @param config - The raw metadata object.
*
* @returns The metadata object.
*/
protected abstract createMetadata(config: TRawMetadata): Promise<TMetadata>;
}
/**
* Provides a base for reading metadata from text-based files within zipped files.
*
* @template TMetadata - Represents the processed metadata object.
* @template TRawMetadata - Represents the raw metadata object to be transformed.
*/
export abstract class ZippedTextLoaderMetadataReader<TMetadata extends LoaderMetadata, TRawMetadata> extends ZippedLoaderMetadataReader<TMetadata, TRawMetadata> {
/**
* A function to transform the raw metadata into a processed metadata object.
*/
private readonly _factory: (raw: TRawMetadata) => Awaitable<TMetadata>;
/**
* A function to parse the text content into a raw metadata object.
*/
private readonly _parser: (text: string) => Awaitable<TRawMetadata>;
/**
* Constructs a new {@link ZippedTextLoaderMetadataReader} instance.
*
* @param entry - The name of the entry inside the zipped file to read.
* @param factory - A function to transform the raw metadata into a processed metadata object.
* @param parser - A function to parse the text content into a raw metadata object.
*/
protected constructor(entry: string, factory: (raw: TRawMetadata) => Awaitable<TMetadata>, parser: (text: string) => Awaitable<TRawMetadata>) {
super(entry);
this._factory = factory;
this._parser = parser;
}
/**
* @inheritdoc
*/
protected async readRawMetadata(buffer: Buffer): Promise<TRawMetadata> {
return await this._parser(buffer.toString());
}
/**
* @inheritdoc
*/
protected async createMetadata(config: TRawMetadata): Promise<TMetadata> {
return await this._factory(config);
}
}

View file

@ -1,7 +1,7 @@
import { McPublishInput, McPublishOutput } from "@/action"; import { McPublishInput, McPublishOutput } from "@/action";
import { GameVersionFilter, getGameVersionProviderByName } from "@/games"; import { GameVersionFilter, getGameVersionProviderByName } from "@/games";
import { MINECRAFT } from "@/games/minecraft"; import { MINECRAFT } from "@/games/minecraft";
import { LoaderMetadataReader, createDefaultLoaderMetadataReader } from "@/loaders"; import { LoaderMetadata, LoaderMetadataReader, createDefaultLoaderMetadataReader } from "@/loaders";
import { PlatformType, createPlatformUploader } from "@/platforms"; import { PlatformType, createPlatformUploader } from "@/platforms";
import { GitHubContext } from "@/platforms/github"; import { GitHubContext } from "@/platforms/github";
import { SPLIT_BY_WORDS_AND_GROUP_ACTION_PARAMETER_PATH_PARSER, createActionOutputControllerUsingMetadata, getActionOutput, getAllActionInputsAsObjectUsingMetadata, parseActionMetadataFromFile, setActionOutput } from "@/utils/actions"; import { SPLIT_BY_WORDS_AND_GROUP_ACTION_PARAMETER_PATH_PARSER, createActionOutputControllerUsingMetadata, getActionOutput, getAllActionInputsAsObjectUsingMetadata, parseActionMetadataFromFile, setActionOutput } from "@/utils/actions";
@ -103,7 +103,7 @@ async function fillInDefaultValues<T extends McPublishInput[P], P extends Platfo
options = { ...options }; options = { ...options };
const primaryFile = options.files[0]; const primaryFile = options.files[0];
const metadata = await reader?.readMetadataFile(primaryFile.path); const metadata = await reader?.readMetadataFile(primaryFile.path).catch(() => undefined as LoaderMetadata);
const gameVersionProvider = getGameVersionProviderByName(metadata?.gameName || MINECRAFT); const gameVersionProvider = getGameVersionProviderByName(metadata?.gameName || MINECRAFT);
const wrappedGameVersions = options.gameVersions?.length ? options.gameVersions : (metadata?.gameVersions || []); const wrappedGameVersions = options.gameVersions?.length ? options.gameVersions : (metadata?.gameVersions || []);

View file

@ -23,19 +23,15 @@ describe("FabricMetadataReader", () => {
expect(metadata).toBeInstanceOf(FabricMetadata); expect(metadata).toBeInstanceOf(FabricMetadata);
}); });
test("returns undefined if file is not a Fabric mod", async () => { test("throws if file is not a Fabric mod", async () => {
const reader = new FabricMetadataReader(); const reader = new FabricMetadataReader();
const metadata = await reader.readMetadataFile("text.txt"); await expect(reader.readMetadataFile("text.txt")).rejects.toThrow();
expect(metadata).toBeUndefined();
}); });
test("returns undefined if file does not exist", async () => { test("throws if file does not exist", async () => {
const reader = new FabricMetadataReader(); const reader = new FabricMetadataReader();
const metadata = await reader.readMetadataFile("text.json"); await expect(reader.readMetadataFile("text.json")).rejects.toThrow();
expect(metadata).toBeUndefined();
}); });
}); });

View file

@ -6,7 +6,6 @@ import { ForgeMetadataReader } from "@/loaders/forge/forge-metadata-reader";
beforeEach(async () => { beforeEach(async () => {
mockFs({ mockFs({
"forge.mod.jar": await zipFile([__dirname, "../../../content/forge/mods.toml"], "META-INF/mods.toml"), "forge.mod.jar": await zipFile([__dirname, "../../../content/forge/mods.toml"], "META-INF/mods.toml"),
"neoforge.mod.jar": await zipFile([__dirname, "../../../content/neoforge/mods.toml"], "META-INF/mods.toml"),
"text.txt": "", "text.txt": "",
}); });
}); });
@ -16,7 +15,7 @@ afterEach(() => {
}); });
describe("ForgeMetadataReader", () => { describe("ForgeMetadataReader", () => {
test("successfully reads forge/mods.toml", async () => { test("successfully reads mods.toml", async () => {
const reader = new ForgeMetadataReader(); const reader = new ForgeMetadataReader();
const metadata = await reader.readMetadataFile("forge.mod.jar"); const metadata = await reader.readMetadataFile("forge.mod.jar");
@ -24,27 +23,15 @@ describe("ForgeMetadataReader", () => {
expect(metadata).toBeInstanceOf(ForgeMetadata); expect(metadata).toBeInstanceOf(ForgeMetadata);
}); });
test("successfully reads neoforge/mods.toml", async () => { test("throws if file is not a Forge mod", async () => {
const reader = new ForgeMetadataReader(); const reader = new ForgeMetadataReader();
const metadata = await reader.readMetadataFile("neoforge.mod.jar"); await expect(reader.readMetadataFile("text.txt")).rejects.toThrow();
expect(metadata).toBeInstanceOf(ForgeMetadata);
}); });
test("returns undefined if file is not a Forge mod", async () => { test("throws if file does not exist", async () => {
const reader = new ForgeMetadataReader(); const reader = new ForgeMetadataReader();
const metadata = await reader.readMetadataFile("text.txt"); await expect(reader.readMetadataFile("text.json")).rejects.toThrow();
expect(metadata).toBeUndefined();
});
test("returns undefined if file does not exist", async () => {
const reader = new ForgeMetadataReader();
const metadata = await reader.readMetadataFile("text.json");
expect(metadata).toBeUndefined();
}); });
}); });

View file

@ -43,20 +43,18 @@ describe("combineLoaderMetadataReaders", () => {
expect(reader2.readMetadataFile).toHaveBeenCalledWith("2"); expect(reader2.readMetadataFile).toHaveBeenCalledWith("2");
}); });
test("combined reader returns undefined when no reader can read the metadata", async () => { test("combined reader throws when no reader can read the metadata", async () => {
const combined = combineLoaderMetadataReaders([]); const combined = combineLoaderMetadataReaders([]);
const metadata = await combined.readMetadataFile("test");
expect(metadata).toBeUndefined(); await expect(combined.readMetadataFile("test")).rejects.toThrow();
}); });
test("combined reader returns undefined instead of throwing", async () => { test("combined reader throws if all underlying readers throw", async () => {
const reader1 = { readMetadataFile: jest.fn().mockRejectedValue(new Error("Cannot read the metadata file")) } as LoaderMetadataReader; const reader1 = { readMetadataFile: jest.fn().mockRejectedValue(new Error("Cannot read the metadata file")) } as LoaderMetadataReader;
const combined = combineLoaderMetadataReaders([reader1]); const combined = combineLoaderMetadataReaders([reader1]);
const metadata = await combined.readMetadataFile("test");
expect(metadata).toBeUndefined(); await expect(combined.readMetadataFile("test")).rejects.toThrow();
}); });
}); });
@ -77,21 +75,19 @@ describe("createLoaderMetadataReader", () => {
} }
}); });
test("created reader returns undefined for unsupported metadata files", async () => { test("created reader throws for unsupported metadata files", async () => {
for (const loader of LoaderType.values()) { for (const loader of LoaderType.values()) {
const reader = createLoaderMetadataReader(loader); const reader = createLoaderMetadataReader(loader);
const metadata = await reader.readMetadataFile("text.txt");
expect(metadata).toBeUndefined(); await expect(reader.readMetadataFile("text.txt")).rejects.toThrow();
} }
}); });
test("created reader returns undefined for non-existing files", async () => { test("created reader throws for non-existing files", async () => {
for (const loader of LoaderType.values()) { for (const loader of LoaderType.values()) {
const reader = createLoaderMetadataReader(loader); const reader = createLoaderMetadataReader(loader);
const metadata = await reader.readMetadataFile("text.json");
expect(metadata).toBeUndefined(); await expect(reader.readMetadataFile("text.json")).rejects.toThrow();
} }
}); });

View file

@ -23,19 +23,15 @@ describe("QuiltMetadataReader", () => {
expect(metadata).toBeInstanceOf(QuiltMetadata); expect(metadata).toBeInstanceOf(QuiltMetadata);
}); });
test("returns undefined if file is not a Quilt mod", async () => { test("throws if file is not a Quilt mod", async () => {
const reader = new QuiltMetadataReader(); const reader = new QuiltMetadataReader();
const metadata = await reader.readMetadataFile("text.txt"); await expect(reader.readMetadataFile("text.txt")).rejects.toThrow();
expect(metadata).toBeUndefined();
}); });
test("returns undefined if file does not exist", async () => { test("throws if file does not exist", async () => {
const reader = new QuiltMetadataReader(); const reader = new QuiltMetadataReader();
const metadata = await reader.readMetadataFile("text.json"); await expect(reader.readMetadataFile("text.json")).rejects.toThrow();
expect(metadata).toBeUndefined();
}); });
}); });

View file

@ -1,100 +0,0 @@
import { zipContent } from "@/../tests/utils/zip-utils";
import { LoaderMetadata } from "@/loaders/loader-metadata";
import mockFs from "mock-fs";
import { ZippedLoaderMetadataReader, ZippedTextLoaderMetadataReader } from "@/loaders/zipped-loader-metadata-reader";
class MockZippedLoaderMetadataReader extends ZippedLoaderMetadataReader<LoaderMetadata, string> {
constructor(entry: string) {
super(entry);
}
protected readRawMetadata(buffer: Buffer): Promise<string> {
return Promise.resolve(buffer.toString());
}
protected createMetadata(config: string): Promise<LoaderMetadata> {
return Promise.resolve({ id: config } as LoaderMetadata);
}
}
class MockZippedTextLoaderMetadataReader extends ZippedTextLoaderMetadataReader<LoaderMetadata, string> {
constructor(entry: string, factory: (raw: string) => LoaderMetadata, parser: (text: string) => string) {
super(entry, factory, parser);
}
}
beforeEach(async () => {
mockFs({
"test.zip": await zipContent("Test", "test.txt"),
});
});
afterEach(() => {
mockFs.restore();
});
describe("ZippedLoaderMetadataReader", () => {
test("reads metadata file from a zipped file at the given path", async () => {
const reader = new MockZippedLoaderMetadataReader("test.txt");
const metadata = await reader.readMetadataFile("test.zip");
expect(metadata).toMatchObject({ id: "Test" });
});
test("returns undefined if the given path does not exist", async () => {
const reader = new MockZippedLoaderMetadataReader("test.txt");
const metadata = await reader.readMetadataFile("");
expect(metadata).toBeUndefined();
});
test("returns undefined if the zip entry does not exist", async () => {
const reader = new MockZippedLoaderMetadataReader("test");
const metadata = await reader.readMetadataFile("test.zip");
expect(metadata).toBeUndefined();
});
});
describe("ZippedTextLoaderMetadataReader", () => {
test("reads metadata file from a zipped file at the given path", async () => {
const factory = jest.fn().mockImplementation(x => ({ id: x }));
const parse = jest.fn().mockImplementation(x => [...String(x)].reverse().join(""));
const reader = new MockZippedTextLoaderMetadataReader("test.txt", factory, parse);
const metadata = await reader.readMetadataFile("test.zip");
expect(metadata).toMatchObject({ id: "tseT" });
expect(factory).toHaveBeenCalledTimes(1);
expect(factory).toHaveBeenCalledWith("tseT");
expect(parse).toHaveBeenCalledTimes(1);
expect(parse).toHaveBeenCalledWith("Test");
});
test("returns undefined if the given path does not exist", async () => {
const factory = jest.fn();
const parse = jest.fn();
const reader = new MockZippedTextLoaderMetadataReader("test.txt", factory, parse);
const metadata = await reader.readMetadataFile("");
expect(metadata).toBeUndefined();
expect(factory).not.toHaveBeenCalled();
expect(parse).not.toHaveBeenCalled();
});
test("returns undefined if the zip entry does not exist", async () => {
const factory = jest.fn();
const parse = jest.fn();
const reader = new MockZippedTextLoaderMetadataReader("test", factory, parse);
const metadata = await reader.readMetadataFile("test.zip");
expect(metadata).toBeUndefined();
expect(factory).not.toHaveBeenCalled();
expect(parse).not.toHaveBeenCalled();
});
});