From ebd0d57b1e7892372790a22951a4ffb971d343aa Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:35:37 -0300 Subject: [PATCH 1/4] feat: local file metadata provider --- .../src/exceptions/fileNotFound.exception.ts | 5 + packages/metadata/src/exceptions/index.ts | 1 + packages/metadata/src/external.ts | 8 +- packages/metadata/src/providers/index.ts | 1 + .../providers/localFileMetadata.provider.ts | 95 ++++++++ .../test/fixtures/metadata.fixtures.ts | 6 +- .../localFileMetadata.provider.spec.ts | 227 ++++++++++++++++++ 7 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 packages/metadata/src/exceptions/fileNotFound.exception.ts create mode 100644 packages/metadata/src/providers/localFileMetadata.provider.ts create mode 100644 packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts diff --git a/packages/metadata/src/exceptions/fileNotFound.exception.ts b/packages/metadata/src/exceptions/fileNotFound.exception.ts new file mode 100644 index 0000000..5ab487d --- /dev/null +++ b/packages/metadata/src/exceptions/fileNotFound.exception.ts @@ -0,0 +1,5 @@ +export class FileNotFound extends Error { + constructor(path: string) { + super(`File not found at path: ${path}`); + } +} diff --git a/packages/metadata/src/exceptions/index.ts b/packages/metadata/src/exceptions/index.ts index b630137..3f40ae4 100644 --- a/packages/metadata/src/exceptions/index.ts +++ b/packages/metadata/src/exceptions/index.ts @@ -1,2 +1,3 @@ export * from "./invalidSchema.exception.js"; export * from "./fetchError.exception.js"; +export * from "./fileNotFound.exception.js"; diff --git a/packages/metadata/src/external.ts b/packages/metadata/src/external.ts index 5d2f0ff..2955e52 100644 --- a/packages/metadata/src/external.ts +++ b/packages/metadata/src/external.ts @@ -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"; diff --git a/packages/metadata/src/providers/index.ts b/packages/metadata/src/providers/index.ts index 7011ce4..74b64bc 100644 --- a/packages/metadata/src/providers/index.ts +++ b/packages/metadata/src/providers/index.ts @@ -1,2 +1,3 @@ export * from "./githubMetadata.provider.js"; export * from "./staticMetadata.provider.js"; +export * from "./localFileMetadata.provider.js"; diff --git a/packages/metadata/src/providers/localFileMetadata.provider.ts b/packages/metadata/src/providers/localFileMetadata.provider.ts new file mode 100644 index 0000000..6f0a09d --- /dev/null +++ b/packages/metadata/src/providers/localFileMetadata.provider.ts @@ -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 { + let cachedData = await this.cache.get( + `${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()); + + await this.cache.set(`${LOCALFILE_METADATA_PREFIX}-chains`, cachedData); + } + + return cachedData; + } + + async getTokensMetadata(): Promise[]> { + let cachedData = await this.cache.get[]>( + `${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; + } +} diff --git a/packages/metadata/test/fixtures/metadata.fixtures.ts b/packages/metadata/test/fixtures/metadata.fixtures.ts index 2e5422e..779f2d3 100644 --- a/packages/metadata/test/fixtures/metadata.fixtures.ts +++ b/packages/metadata/test/fixtures/metadata.fixtures.ts @@ -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[] = [ { name: "Ethereum", symbol: "ETH", @@ -20,7 +22,7 @@ export const mockTokenData = [ type: "erc20", decimals: 18, }, -]; +] as const; export const mockChainData = [ { chainId: 324, diff --git a/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts b/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts new file mode 100644 index 0000000..3f42466 --- /dev/null +++ b/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts @@ -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(); + 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(); + 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); + }); + }); +}); From f9ae12be03027e761366cd5e5bb2f4fee362e17a Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:57:39 -0300 Subject: [PATCH 2/4] feat: add cache to github provider --- .../src/providers/githubMetadata.provider.ts | 78 +++++--- .../providers/githubMetadata.provider.spec.ts | 172 ++++++++++++++++-- 2 files changed, 211 insertions(+), 39 deletions(-) diff --git a/packages/metadata/src/providers/githubMetadata.provider.ts b/packages/metadata/src/providers/githubMetadata.provider.ts index 4c533e5..a64dba8 100644 --- a/packages/metadata/src/providers/githubMetadata.provider.ts +++ b/packages/metadata/src/providers/githubMetadata.provider.ts @@ -2,6 +2,7 @@ import axios, { AxiosInstance } from "axios"; import { z } from "zod"; import { + Cache, ILogger, Token, TokenType, @@ -13,6 +14,8 @@ import { IMetadataProvider } from "../interfaces/index.js"; import { FetchError, InvalidSchema } from "../internal.js"; import { ChainSchema, TokenSchema } from "../schemas/index.js"; +export const GITHUB_METADATA_PREFIX = "github-metadata"; + /** * Represents a provider for retrieving metadata from GitHub. */ @@ -22,6 +25,7 @@ export class GithubMetadataProvider implements IMetadataProvider { private readonly tokenJsonUrl: string, private readonly chainJsonUrl: string, private readonly logger: ILogger, + private readonly cache: Cache, ) { this.axios = axios.create({ headers: { @@ -31,43 +35,61 @@ export class GithubMetadataProvider implements IMetadataProvider { } async getChainsMetadata(): Promise { - const { data } = await this.axios.get(this.chainJsonUrl).catch((e) => { - this.logger.error( - `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, - ); - throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); - }); + let cachedData = await this.cache.get( + `${GITHUB_METADATA_PREFIX}-chains`, + ); + if (!cachedData) { + const { data } = await this.axios.get(this.chainJsonUrl).catch((e) => { + this.logger.error( + `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, + ); + throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); + }); + + const validatedData = z.array(ChainSchema).safeParse(data); - const validatedData = z.array(ChainSchema).safeParse(data); + if (!validatedData.success) { + this.logger.error(`Invalid ZKChain metadata: ${validatedData.error.errors}`); + throw new InvalidSchema("Invalid ZKChain metadata"); + } - if (!validatedData.success) { - this.logger.error(`Invalid ZKChain metadata: ${validatedData.error.errors}`); - throw new InvalidSchema("Invalid ZKChain 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()); + + await this.cache.set(`${GITHUB_METADATA_PREFIX}-chains`, cachedData); } - return validatedData.data.reduce((acc, chain) => { - const { chainId, ...rest } = chain; - const chainIdBn = BigInt(chainId); - acc.set(chainIdBn, { ...rest, chainId: chainIdBn }); - return acc; - }, new Map()); + return cachedData; } async getTokensMetadata(): Promise[]> { - const { data } = await this.axios.get(this.tokenJsonUrl).catch((e) => { - this.logger.error( - `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, - ); - throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); - }); + let cachedData = await this.cache.get[] | undefined>( + `${GITHUB_METADATA_PREFIX}-tokens`, + ); - const validatedData = z.array(TokenSchema).safeParse(data); + if (!cachedData) { + const { data } = await this.axios.get(this.tokenJsonUrl).catch((e) => { + this.logger.error( + `Failed to fetch chains metadata from ${this.chainJsonUrl}: ${e.message}`, + ); + throw new FetchError(`Failed to fetch chains metadata: ${e.message}`); + }); - if (!validatedData.success) { - this.logger.error(`Invalid Token metadata: ${validatedData.error.errors}`); - throw new InvalidSchema("Invalid Token metadata"); - } + const validatedData = z.array(TokenSchema).safeParse(data); + + if (!validatedData.success) { + this.logger.error(`Invalid Token metadata: ${validatedData.error.errors}`); + throw new InvalidSchema("Invalid Token metadata"); + } - return validatedData.data; + cachedData = validatedData.data; + + await this.cache.set(`${GITHUB_METADATA_PREFIX}-tokens`, cachedData); + } + return cachedData; } } diff --git a/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts b/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts index 725b07b..f40860b 100644 --- a/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts +++ b/packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts @@ -1,7 +1,7 @@ import MockAdapter from "axios-mock-adapter"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ILogger, ZKChainMetadataItem } from "@zkchainhub/shared"; +import { Cache, ILogger, ZKChainMetadataItem } from "@zkchainhub/shared"; import { FetchError, InvalidSchema } from "../../../src/internal"; import { GithubMetadataProvider } from "../../../src/providers/githubMetadata.provider"; @@ -19,15 +19,102 @@ const mockLogger: ILogger = { debug: vi.fn(), }; +const mockCache: Cache = { + store: {} as any, + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + reset: vi.fn(), +}; + describe("GithubMetadataProvider", () => { beforeEach(() => { vi.resetAllMocks(); }); describe("getChainsMetadata", () => { - it("should return the chains metadata", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + it("return the cached chains metadata", async () => { + const cachedData = new Map([ + [ + 324n, + { + chainId: 324n, + name: "ZKsyncERA", + iconUrl: "https://s2.coinmarketcap.com/static/img/coins/64x64/24091.png", + publicRpcs: [ + "https://mainnet.era.zksync.io", + "https://zksync.drpc.org", + "https://zksync.meowrpc.com", + ], + explorerUrl: "https://explorer.zksync.io/", + launchDate: 1679626800, + chainType: "Rollup", + baseToken: { + name: "Ethereum", + symbol: "ETH", + coingeckoId: "ethereum", + type: "native", + contractAddress: null, + decimals: 18, + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + }, + }, + ], + ]); + vi.spyOn(mockCache, "get").mockResolvedValue(cachedData); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); + + const axiosSpy = vi.spyOn(provider["axios"], "get"); + const result = await provider.getChainsMetadata(); + + expect(axiosSpy).not.toHaveBeenCalled(); + expect(mockCache.get).toHaveBeenCalledWith("github-metadata-chains"); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(1); + + const chain1 = result.get(324n) as ZKChainMetadataItem; + expect(chain1).toBeDefined(); + expect(chain1).toMatchObject({ + chainId: 324n, + name: "ZKsyncERA", + iconUrl: "https://s2.coinmarketcap.com/static/img/coins/64x64/24091.png", + publicRpcs: [ + "https://mainnet.era.zksync.io", + "https://zksync.drpc.org", + "https://zksync.meowrpc.com", + ], + explorerUrl: "https://explorer.zksync.io/", + launchDate: 1679626800, + chainType: "Rollup", + baseToken: { + name: "Ethereum", + symbol: "ETH", + coingeckoId: "ethereum", + type: "native", + contractAddress: null, + decimals: 18, + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + }, + }); + }); + + it("fetches and return the chains metadata", async () => { + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = provider["axios"]; + + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); const axiosGetMock = vi .spyOn(axios, "get") .mockResolvedValueOnce({ data: mockChainData }); @@ -35,6 +122,7 @@ describe("GithubMetadataProvider", () => { const result = await provider.getChainsMetadata(); expect(axiosGetMock).toHaveBeenCalledWith(chainJsonUrl); + expect(mockCache.set).toHaveBeenCalled(); expect(result).toBeInstanceOf(Map); expect(result.size).toBe(2); @@ -86,9 +174,15 @@ describe("GithubMetadataProvider", () => { }); it("should throw an error if the schema is invalid", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = provider["axios"]; + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); const axiosGetMock = vi .spyOn(axios, "get") .mockResolvedValueOnce({ data: [{ invalid: "data" }] }); @@ -98,9 +192,15 @@ describe("GithubMetadataProvider", () => { }); it("should throw an error if the fetch fails with 404 error", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = new MockAdapter(provider["axios"]); + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); axios.onGet().replyOnce(404, { data: {}, status: 404, @@ -111,9 +211,15 @@ describe("GithubMetadataProvider", () => { }); it("should throw an error if the fetch fails with 500 error", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = new MockAdapter(provider["axios"]); + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); axios.onGet().replyOnce(500, { data: {}, status: 500, @@ -125,9 +231,33 @@ describe("GithubMetadataProvider", () => { }); describe("getTokensMetadata", () => { - it("should return the tokens metadata", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + it("returns cached tokens metadata", async () => { + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); + const axiosSpy = vi.spyOn(provider["axios"], "get"); + vi.spyOn(mockCache, "get").mockResolvedValue(mockTokenData); + + const result = await provider.getTokensMetadata(); + + expect(mockCache.get).toHaveBeenCalledWith("github-metadata-tokens"); + expect(axiosSpy).not.toHaveBeenCalled(); + expect(result).toEqual(mockTokenData); + }); + + it("fetches and return the tokens metadata", async () => { + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = provider["axios"]; + + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); const axiosGetMock = vi .spyOn(axios, "get") .mockResolvedValueOnce({ data: mockTokenData }); @@ -135,12 +265,20 @@ describe("GithubMetadataProvider", () => { const result = await provider.getTokensMetadata(); expect(axiosGetMock).toHaveBeenCalledWith(tokenJsonUrl); + expect(mockCache.set).toHaveBeenCalledWith("github-metadata-tokens", mockTokenData); expect(result).toEqual(mockTokenData); }); it("should throw an error if the schema is invalid", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = provider["axios"]; + + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); const axiosGetMock = vi .spyOn(axios, "get") .mockResolvedValueOnce({ data: [{ invalid: "data" }] }); @@ -150,9 +288,15 @@ describe("GithubMetadataProvider", () => { }); it("should throw an error if the fetch fails with 404 error", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = new MockAdapter(provider["axios"]); + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); axios.onGet().replyOnce(404, { data: {}, status: 404, @@ -163,9 +307,15 @@ describe("GithubMetadataProvider", () => { }); it("should throw an error if the fetch fails with 500 error", async () => { - const provider = new GithubMetadataProvider(tokenJsonUrl, chainJsonUrl, mockLogger); + const provider = new GithubMetadataProvider( + tokenJsonUrl, + chainJsonUrl, + mockLogger, + mockCache, + ); const axios = new MockAdapter(provider["axios"]); + vi.spyOn(mockCache, "get").mockResolvedValue(undefined); axios.onGet().replyOnce(500, { data: {}, status: 500, From 3c3917092bbf7241ad8d08c7d733553ea38356c8 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 23 Aug 2024 18:50:32 -0300 Subject: [PATCH 3/4] docs: add tsdocs --- .../src/interfaces/metadata.interface.ts | 24 +++++++++++++++++++ .../src/providers/githubMetadata.provider.ts | 2 ++ .../providers/localFileMetadata.provider.ts | 4 +++- .../src/providers/staticMetadata.provider.ts | 6 +++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/metadata/src/interfaces/metadata.interface.ts b/packages/metadata/src/interfaces/metadata.interface.ts index 1313b14..929aaed 100644 --- a/packages/metadata/src/interfaces/metadata.interface.ts +++ b/packages/metadata/src/interfaces/metadata.interface.ts @@ -1,6 +1,30 @@ import { Token, TokenType, ZKChainMetadata } from "@zkchainhub/shared"; +/** + * Represents a metadata provider that retrieves chains and tokens metadata. + */ export interface IMetadataProvider { + /** + * Retrieves the metadata for ZK chains of the ecosystem + * + * @returns A promise that resolves to the metadata of ZK chains. + * + * @throws {FetchError} + * If there is an issue with the network request. + * + * + * @throws {InvalidSchema} + * If the response data is invalid or cannot be parsed. + */ getChainsMetadata(): Promise; + + /** + * Retrieves metadata for tokens of the ecosystem + * + * @returns A promise that resolves to an array of token metadata. + * + * @throws {FetchError} If there is an issue with the network request. + * @throws {InvalidSchema} If the response data is invalid or cannot be parsed. + */ getTokensMetadata(): Promise[]>; } diff --git a/packages/metadata/src/providers/githubMetadata.provider.ts b/packages/metadata/src/providers/githubMetadata.provider.ts index a64dba8..b38fa1b 100644 --- a/packages/metadata/src/providers/githubMetadata.provider.ts +++ b/packages/metadata/src/providers/githubMetadata.provider.ts @@ -34,6 +34,7 @@ export class GithubMetadataProvider implements IMetadataProvider { }); } + /** @inheritdoc */ async getChainsMetadata(): Promise { let cachedData = await this.cache.get( `${GITHUB_METADATA_PREFIX}-chains`, @@ -66,6 +67,7 @@ export class GithubMetadataProvider implements IMetadataProvider { return cachedData; } + /** @inheritdoc */ async getTokensMetadata(): Promise[]> { let cachedData = await this.cache.get[] | undefined>( `${GITHUB_METADATA_PREFIX}-tokens`, diff --git a/packages/metadata/src/providers/localFileMetadata.provider.ts b/packages/metadata/src/providers/localFileMetadata.provider.ts index 6f0a09d..771d1cf 100644 --- a/packages/metadata/src/providers/localFileMetadata.provider.ts +++ b/packages/metadata/src/providers/localFileMetadata.provider.ts @@ -16,7 +16,7 @@ import { ChainSchema, TokenSchema } from "../schemas/index.js"; export const LOCALFILE_METADATA_PREFIX = "local-metadata"; /** - * Represents a local file metadata provider. + * Represents a provider that retrieves metadata from local files. */ export class LocalFileMetadataProvider implements IMetadataProvider { /** @@ -42,6 +42,7 @@ export class LocalFileMetadataProvider implements IMetadataProvider { } } + /** @inheritdoc */ async getChainsMetadata(): Promise { let cachedData = await this.cache.get( `${LOCALFILE_METADATA_PREFIX}-chains`, @@ -70,6 +71,7 @@ export class LocalFileMetadataProvider implements IMetadataProvider { return cachedData; } + /** @inheritdoc */ async getTokensMetadata(): Promise[]> { let cachedData = await this.cache.get[]>( `${LOCALFILE_METADATA_PREFIX}-tokens`, diff --git a/packages/metadata/src/providers/staticMetadata.provider.ts b/packages/metadata/src/providers/staticMetadata.provider.ts index 39b08c4..fda0440 100644 --- a/packages/metadata/src/providers/staticMetadata.provider.ts +++ b/packages/metadata/src/providers/staticMetadata.provider.ts @@ -2,10 +2,16 @@ import { Token, tokens, TokenType, ZKChainMetadata, zkChainsMetadata } from "@zk import { IMetadataProvider } from "../interfaces/index.js"; +/** + * Represents a provider that retrieves metadata from static data of mainnet. + */ export class StaticMetadataProvider implements IMetadataProvider { + /** @inheritdoc */ async getChainsMetadata(): Promise { return structuredClone(zkChainsMetadata); } + + /** @inheritdoc */ async getTokensMetadata(): Promise[]> { return Array.from(tokens); } From 7f5b6643a6532deb390bd9fe846555586bc55f30 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Mon, 26 Aug 2024 10:29:16 -0300 Subject: [PATCH 4/4] feat: remove cache from local provider and save in instance variable --- .../providers/localFileMetadata.provider.ts | 76 ++++--- .../localFileMetadata.provider.spec.ts | 193 ++++-------------- 2 files changed, 76 insertions(+), 193 deletions(-) diff --git a/packages/metadata/src/providers/localFileMetadata.provider.ts b/packages/metadata/src/providers/localFileMetadata.provider.ts index 771d1cf..a53e4ec 100644 --- a/packages/metadata/src/providers/localFileMetadata.provider.ts +++ b/packages/metadata/src/providers/localFileMetadata.provider.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from "fs"; import { z } from "zod"; import { - Cache, ILogger, Token, TokenType, @@ -17,21 +16,23 @@ export const LOCALFILE_METADATA_PREFIX = "local-metadata"; /** * Represents a provider that retrieves metadata from local files. + * Note: Files are read only once and saved to instance variables. */ export class LocalFileMetadataProvider implements IMetadataProvider { + private readonly chainMetadata: ZKChainMetadata; + private readonly tokenMetadata: Token[]; + /** * 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); @@ -40,58 +41,51 @@ export class LocalFileMetadataProvider implements IMetadataProvider { if (!existsSync(chainJsonPath)) { throw new FileNotFound(chainJsonPath); } + + this.tokenMetadata = this.readAndParseTokenMetadata(); + this.chainMetadata = this.readAndParseChainMetadata(); } /** @inheritdoc */ async getChainsMetadata(): Promise { - let cachedData = await this.cache.get( - `${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()); - - await this.cache.set(`${LOCALFILE_METADATA_PREFIX}-chains`, cachedData); + return Promise.resolve(this.chainMetadata); + } + + readAndParseChainMetadata() { + 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"); } - return cachedData; + return validatedData.data.reduce((acc, chain) => { + const { chainId, ...rest } = chain; + const chainIdBn = BigInt(chainId); + acc.set(chainIdBn, { ...rest, chainId: chainIdBn }); + return acc; + }, new Map()); } /** @inheritdoc */ async getTokensMetadata(): Promise[]> { - let cachedData = await this.cache.get[]>( - `${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); + return Promise.resolve(this.tokenMetadata); + } - if (!validatedData.success) { - this.logger.error(`Invalid Tokens metadata: ${validatedData.error.errors}`); - throw new InvalidSchema("Invalid Tokens metadata"); - } + readAndParseTokenMetadata() { + const jsonData = readFileSync(this.tokenJsonPath, "utf-8"); + const parsed = JSON.parse(jsonData); - cachedData = validatedData.data; + const validatedData = z.array(TokenSchema).safeParse(parsed); - await this.cache.set(`${LOCALFILE_METADATA_PREFIX}-tokens`, cachedData); + if (!validatedData.success) { + this.logger.error(`Invalid Tokens metadata: ${validatedData.error.errors}`); + throw new InvalidSchema("Invalid Tokens metadata"); } - return cachedData; + return validatedData.data; } } diff --git a/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts b/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts index 3f42466..26cc0df 100644 --- a/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts +++ b/packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Cache, ILogger, ZKChainMetadataItem } from "@zkchainhub/shared"; +import { ILogger } from "@zkchainhub/shared"; import { FileNotFound, InvalidSchema, LocalFileMetadataProvider } from "../../../src/internal"; import { mockChainData, mockTokenData } from "../../fixtures/metadata.fixtures.js"; @@ -14,14 +14,6 @@ const mockLogger: ILogger = { 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(), @@ -38,13 +30,7 @@ describe("LocalFileMetadataProvider", () => { vi.spyOn(fs, "existsSync").mockReturnValueOnce(false); expect( - () => - new LocalFileMetadataProvider( - "token.json", - "chain.json", - mockLogger, - mockCache, - ), + () => new LocalFileMetadataProvider("token.json", "chain.json", mockLogger), ).toThrow(FileNotFound); }); @@ -52,76 +38,36 @@ describe("LocalFileMetadataProvider", () => { vi.spyOn(fs, "existsSync").mockReturnValueOnce(true).mockReturnValueOnce(false); expect( - () => - new LocalFileMetadataProvider( - "token.json", - "chain.json", - mockLogger, - mockCache, - ), + () => new LocalFileMetadataProvider("token.json", "chain.json", mockLogger), ).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(); - }); - }); + it("throws error on token schema validation", 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", + }, + ]; - describe("getChainsMetadata", () => { - it("return the cached chain data if it exists", async () => { - const cachedData = new Map(); 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(); + vi.spyOn(fs, "readFileSync").mockReturnValueOnce(JSON.stringify(invalidTokenData)); - 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(); - 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); + expect( + () => new LocalFileMetadataProvider("token.json", "chain.json", mockLogger), + ).toThrow(InvalidSchema); }); - it("throws an error if schema validation fails", async () => { + it("throws error on chain schema validation", async () => { const invalidChainData = [ { name: "Ethereum", @@ -140,88 +86,31 @@ describe("LocalFileMetadataProvider", () => { }, ]; - 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); + vi.spyOn(fs, "readFileSync") + .mockReturnValueOnce(JSON.stringify(mockTokenData)) + .mockReturnValue(JSON.stringify(invalidChainData)); - 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(); + expect( + () => new LocalFileMetadataProvider("token.json", "chain.json", mockLogger), + ).toThrow(InvalidSchema); }); - 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)); + it("read, parse and saves to variables the file data", async () => { vi.spyOn(fs, "existsSync").mockReturnValue(true); + vi.spyOn(fs, "readFileSync") + .mockReturnValueOnce(JSON.stringify(mockTokenData)) + .mockReturnValueOnce(JSON.stringify(mockChainData)); - const provider = new LocalFileMetadataProvider( - "token.json", - "chain.json", - mockLogger, - mockCache, - ); - - const result = await provider.getTokensMetadata(); + const provider = new LocalFileMetadataProvider("token.json", "chain.json", mockLogger); + const chainMetadata = await provider.getChainsMetadata(); + const tokenMetadata = await provider.getTokensMetadata(); - expect(result).toEqual(mockTokenData); + expect(provider).toBeDefined(); + expect(tokenMetadata).toEqual(provider["tokenMetadata"]); + expect(chainMetadata).toEqual(provider["chainMetadata"]); + expect(fs.readFileSync).toHaveBeenCalledWith("chain.json", "utf-8"); 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); }); }); });