-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
339 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
95
packages/metadata/src/providers/localFileMetadata.provider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
227 changes: 227 additions & 0 deletions
227
packages/metadata/test/unit/providers/localFileMetadata.provider.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |