Skip to content

Commit

Permalink
feat: local file metadata provider
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Aug 23, 2024
1 parent 41731c6 commit ebd0d57
Show file tree
Hide file tree
Showing 7 changed files with 339 additions and 4 deletions.
5 changes: 5 additions & 0 deletions packages/metadata/src/exceptions/fileNotFound.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class FileNotFound extends Error {
constructor(path: string) {
super(`File not found at path: ${path}`);
}
}
1 change: 1 addition & 0 deletions packages/metadata/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./invalidSchema.exception.js";
export * from "./fetchError.exception.js";
export * from "./fileNotFound.exception.js";
8 changes: 6 additions & 2 deletions packages/metadata/src/external.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export type { IMetadataProvider } from "./internal.js";

export { InvalidSchema, FetchError } from "./internal.js";
export { InvalidSchema, FetchError, FileNotFound } from "./internal.js";

export { StaticMetadataProvider, GithubMetadataProvider } from "./internal.js";
export {
StaticMetadataProvider,
GithubMetadataProvider,
LocalFileMetadataProvider,
} from "./internal.js";
1 change: 1 addition & 0 deletions packages/metadata/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./githubMetadata.provider.js";
export * from "./staticMetadata.provider.js";
export * from "./localFileMetadata.provider.js";
95 changes: 95 additions & 0 deletions packages/metadata/src/providers/localFileMetadata.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { existsSync, readFileSync } from "fs";
import { z } from "zod";

import {
Cache,
ILogger,
Token,
TokenType,
ZKChainMetadata,
ZKChainMetadataItem,
} from "@zkchainhub/shared";

import { FileNotFound, IMetadataProvider, InvalidSchema } from "../internal.js";
import { ChainSchema, TokenSchema } from "../schemas/index.js";

export const LOCALFILE_METADATA_PREFIX = "local-metadata";

/**
* Represents a local file metadata provider.
*/
export class LocalFileMetadataProvider implements IMetadataProvider {
/**
* Constructs a new instance of the LocalFileMetadataProvider class.
* @param tokenJsonPath The path to the token JSON file.
* @param chainJsonPath The path to the chain JSON file.
* @param logger The logger instance.
* @param cache The cache instance.
* @throws {FileNotFound} if any of the files is not found.
*/
constructor(
private readonly tokenJsonPath: string,
private readonly chainJsonPath: string,
private readonly logger: ILogger,
private readonly cache: Cache,
) {
if (!existsSync(tokenJsonPath)) {
throw new FileNotFound(tokenJsonPath);
}

if (!existsSync(chainJsonPath)) {
throw new FileNotFound(chainJsonPath);
}
}

async getChainsMetadata(): Promise<ZKChainMetadata> {
let cachedData = await this.cache.get<ZKChainMetadata>(
`${LOCALFILE_METADATA_PREFIX}-chains`,
);
if (!cachedData) {
const jsonData = readFileSync(this.chainJsonPath, "utf-8");
const parsed = JSON.parse(jsonData);

const validatedData = z.array(ChainSchema).safeParse(parsed);

if (!validatedData.success) {
this.logger.error(`Invalid ZKChains metadata: ${validatedData.error.errors}`);
throw new InvalidSchema("Invalid ZKChains metadata");
}

cachedData = validatedData.data.reduce((acc, chain) => {
const { chainId, ...rest } = chain;
const chainIdBn = BigInt(chainId);
acc.set(chainIdBn, { ...rest, chainId: chainIdBn });
return acc;
}, new Map<bigint, ZKChainMetadataItem>());

await this.cache.set(`${LOCALFILE_METADATA_PREFIX}-chains`, cachedData);
}

return cachedData;
}

async getTokensMetadata(): Promise<Token<TokenType>[]> {
let cachedData = await this.cache.get<Token<TokenType>[]>(
`${LOCALFILE_METADATA_PREFIX}-tokens`,
);
if (!cachedData) {
const jsonData = readFileSync(this.tokenJsonPath, "utf-8");
const parsed = JSON.parse(jsonData);

const validatedData = z.array(TokenSchema).safeParse(parsed);

if (!validatedData.success) {
this.logger.error(`Invalid Tokens metadata: ${validatedData.error.errors}`);
throw new InvalidSchema("Invalid Tokens metadata");
}

cachedData = validatedData.data;

await this.cache.set(`${LOCALFILE_METADATA_PREFIX}-tokens`, cachedData);
}

return cachedData;
}
}
6 changes: 4 additions & 2 deletions packages/metadata/test/fixtures/metadata.fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Token, TokenType } from "@zkchainhub/shared";

export const tokenJsonUrl = "https://example.com/tokens.json";
export const chainJsonUrl = "https://example.com/chains.json";
export const mockTokenData = [
export const mockTokenData: Token<TokenType>[] = [
{
name: "Ethereum",
symbol: "ETH",
Expand All @@ -20,7 +22,7 @@ export const mockTokenData = [
type: "erc20",
decimals: 18,
},
];
] as const;
export const mockChainData = [
{
chainId: 324,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import * as fs from "fs";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { Cache, ILogger, ZKChainMetadataItem } from "@zkchainhub/shared";

import { FileNotFound, InvalidSchema, LocalFileMetadataProvider } from "../../../src/internal";
import { mockChainData, mockTokenData } from "../../fixtures/metadata.fixtures.js";

// Mock the logger
const mockLogger: ILogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};

const mockCache: Cache = {
store: {} as any,
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
};

// Mock the file system functions
vi.mock("fs", () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));

describe("LocalFileMetadataProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("constructor", () => {
it("throws FileNotFound error if token JSON file does not exist", () => {
vi.spyOn(fs, "existsSync").mockReturnValueOnce(false);

expect(
() =>
new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
),
).toThrow(FileNotFound);
});

it("throws FileNotFound error if chain JSON file does not exist", () => {
vi.spyOn(fs, "existsSync").mockReturnValueOnce(true).mockReturnValueOnce(false);

expect(
() =>
new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
),
).toThrow(FileNotFound);
});

it("not throws any error if both token JSON file and chain JSON file exist", () => {
vi.spyOn(fs, "existsSync").mockReturnValue(true);
expect(
() =>
new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
),
).not.toThrow();
});
});

describe("getChainsMetadata", () => {
it("return the cached chain data if it exists", async () => {
const cachedData = new Map<bigint, ZKChainMetadataItem>();
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(mockCache, "get").mockResolvedValue(cachedData);

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

const result = await provider.getChainsMetadata();

expect(result).toEqual(cachedData);
expect(mockCache.get).toHaveBeenCalledWith("local-metadata-chains");
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("read and parse the chain JSON file if the cached chain data does not exist", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(mockChainData));
vi.spyOn(fs, "existsSync").mockReturnValue(true);
const expectedMap = new Map<bigint, ZKChainMetadataItem>();
for (const chain of mockChainData) {
const { chainId, ...rest } = chain;
const chainIdBn = BigInt(chainId);
expectedMap.set(chainIdBn, { ...rest, chainId: chainIdBn } as ZKChainMetadataItem);
}

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

const result = await provider.getChainsMetadata();

expect(result).toEqual(expectedMap);
expect(fs.readFileSync).toHaveBeenCalledWith("chain.json", "utf-8");
expect(mockCache.set).toHaveBeenCalledWith("local-metadata-chains", expectedMap);
});

it("throws an error if schema validation fails", async () => {
const invalidChainData = [
{
name: "Ethereum",
symbol: "ETH",
decimals: 18,
rpcUrl: "https://mainnet.infura.io/v3/your-infura-key",
explorerUrl: "https://etherscan.io",
},
{
chainId: 3,

symbol: "BNB",
decimals: 18,
rpcUrl: "https://bsc-dataseed.binance.org",
explorerUrl: "https://bscscan.com",
},
];

vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(invalidChainData));
vi.spyOn(fs, "existsSync").mockReturnValue(true);

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

await expect(provider.getChainsMetadata()).rejects.toThrow(InvalidSchema);
});
});

describe("getTokensMetadata", () => {
it("returns the cached token data if it exists", async () => {
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(mockCache, "get").mockResolvedValue(mockTokenData);

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

const result = await provider.getTokensMetadata();

expect(result).toBe(mockTokenData);
expect(mockCache.get).toHaveBeenCalledWith("local-metadata-tokens");
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("read and parse the token JSON file if the cached token data does not exist", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(mockTokenData));
vi.spyOn(fs, "existsSync").mockReturnValue(true);

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

const result = await provider.getTokensMetadata();

expect(result).toEqual(mockTokenData);
expect(fs.readFileSync).toHaveBeenCalledWith("token.json", "utf-8");
expect(mockCache.set).toHaveBeenCalledWith("local-metadata-tokens", mockTokenData);
});

it("throws an error if schema validation fails", async () => {
const invalidTokenData = [
{
name: "Ethereum",
symbol: "ETH",
decimals: 18,
rpcUrl: "https://mainnet.infura.io/v3/your-infura-key",
explorerUrl: "https://etherscan.io",
},
{
name: "Wrapped Ether",
decimals: 18.5,
rpcUrl: "https://mainnet.infura.io/v3/your-infura-key",
explorerUrl: "https://etherscan.io",
},
];

vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(invalidTokenData));
vi.spyOn(fs, "existsSync").mockReturnValue(true);

const provider = new LocalFileMetadataProvider(
"token.json",
"chain.json",
mockLogger,
mockCache,
);

await expect(provider.getTokensMetadata()).rejects.toThrow(InvalidSchema);
});
});
});

0 comments on commit ebd0d57

Please sign in to comment.