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] 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,