Skip to content

Commit

Permalink
feat: add cache to github provider
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Aug 23, 2024
1 parent ebd0d57 commit f9ae12b
Show file tree
Hide file tree
Showing 2 changed files with 211 additions and 39 deletions.
78 changes: 50 additions & 28 deletions packages/metadata/src/providers/githubMetadata.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios, { AxiosInstance } from "axios";
import { z } from "zod";

import {
Cache,
ILogger,
Token,
TokenType,
Expand All @@ -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.
*/
Expand All @@ -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: {
Expand All @@ -31,43 +35,61 @@ export class GithubMetadataProvider implements IMetadataProvider {
}

async getChainsMetadata(): Promise<ZKChainMetadata> {
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<ZKChainMetadata | undefined>(
`${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<bigint, ZKChainMetadataItem>());

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<bigint, ZKChainMetadataItem>());
return cachedData;
}

async getTokensMetadata(): Promise<Token<TokenType>[]> {
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<Token<TokenType>[] | 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;
}
}
172 changes: 161 additions & 11 deletions packages/metadata/test/unit/providers/githubMetadata.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,22 +19,110 @@ 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<bigint, ZKChainMetadataItem>([
[
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 });

const result = await provider.getChainsMetadata();

expect(axiosGetMock).toHaveBeenCalledWith(chainJsonUrl);
expect(mockCache.set).toHaveBeenCalled();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);

Expand Down Expand Up @@ -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" }] });
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -125,22 +231,54 @@ 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 });

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" }] });
Expand All @@ -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,
Expand All @@ -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,
Expand Down

0 comments on commit f9ae12b

Please sign in to comment.