-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: ipfs metadata provider #5
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
# Grants Stack Indexer v2: Metadata package | ||
|
||
This package exposes a metadata provider that can be used to retrieved metadata from IPFS. | ||
|
||
## Setup | ||
|
||
1. Install dependencies running `pnpm install` | ||
|
||
## Available Scripts | ||
|
||
Available scripts that can be run using `pnpm`: | ||
|
||
| Script | Description | | ||
| ------------- | ------------------------------------------------------- | | ||
| `build` | Build library using tsc | | ||
| `check-types` | Check types issues using tsc | | ||
| `clean` | Remove `dist` folder | | ||
| `lint` | Run ESLint to check for coding standards | | ||
| `lint:fix` | Run linter and automatically fix code formatting issues | | ||
| `format` | Check code formatting and style using Prettier | | ||
| `format:fix` | Run formatter and automatically fix issues | | ||
| `test` | Run tests using vitest | | ||
| `test:cov` | Run tests with coverage report | | ||
|
||
## Usage | ||
|
||
### Importing the Package | ||
|
||
You can import the package in your TypeScript or JavaScript files as follows: | ||
|
||
```typescript | ||
import { IpfsProvider } from "@grants-stack-indexer/metadata"; | ||
``` | ||
|
||
### Example | ||
|
||
```typescript | ||
const provider = new IpfsProvider(["https://ipfs.io", "https://cloudflare-ipfs.com"]); | ||
const metadata = await provider.getMetadata("QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ"); | ||
``` | ||
|
||
## API | ||
|
||
### [IMetadataProvider](./src/interfaces/metadata.interface.ts) | ||
|
||
Available methods | ||
|
||
- `getMetadata<T>(ipfsCid: string, validateContent?: z.ZodSchema<T>): Promise<T | undefined>` | ||
|
||
## References | ||
|
||
- [IPFS](https://docs.ipfs.tech/reference/http-api/) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
{ | ||
"name": "@grants-stack-indexer/metadata", | ||
"version": "0.0.1", | ||
"private": true, | ||
"description": "", | ||
"license": "MIT", | ||
"author": "Wonderland", | ||
"type": "module", | ||
"main": "./dist/src/index.js", | ||
"types": "./dist/src/index.d.ts", | ||
"directories": { | ||
"src": "src" | ||
}, | ||
"files": [ | ||
"dist/*", | ||
"package.json", | ||
"!**/*.tsbuildinfo" | ||
], | ||
"scripts": { | ||
"build": "tsc -p tsconfig.build.json", | ||
"check-types": "tsc --noEmit -p ./tsconfig.json", | ||
"clean": "rm -rf dist/", | ||
"format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"", | ||
"format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"", | ||
"lint": "eslint \"{src,test}/**/*.{js,ts,json}\"", | ||
"lint:fix": "pnpm lint --fix", | ||
"test": "vitest run --config vitest.config.ts --passWithNoTests", | ||
"test:cov": "vitest run --config vitest.config.ts --coverage" | ||
}, | ||
"dependencies": { | ||
"@grants-stack-indexer/shared": "workspace:0.0.1", | ||
"axios": "1.7.7", | ||
"zod": "3.23.8" | ||
}, | ||
"devDependencies": { | ||
"axios-mock-adapter": "2.0.0" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export class EmptyGatewaysUrlsException extends Error { | ||
constructor() { | ||
super("Gateways array cannot be empty"); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from "./emptyGateways.exception.js"; | ||
export * from "./invalidCid.exception.js"; | ||
export * from "./invalidContent.exception.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export class InvalidCidException extends Error { | ||
constructor(cid: string) { | ||
super(`Invalid CID: ${cid}`); | ||
this.name = "InvalidCidException"; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export class InvalidContentException extends Error { | ||
constructor(message: string) { | ||
super(message); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export { IpfsProvider } from "./internal.js"; | ||
|
||
export { | ||
EmptyGatewaysUrlsException, | ||
InvalidCidException, | ||
InvalidContentException, | ||
} from "./internal.js"; | ||
|
||
export type { IMetadataProvider } from "./internal.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./external.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./metadata.interface.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import z from "zod"; | ||
|
||
/** | ||
* Metadata provider interface | ||
*/ | ||
export interface IMetadataProvider { | ||
/** | ||
* Get metadata from IPFS | ||
* @param ipfsCid - IPFS CID | ||
* @returns - Metadata | ||
* @throws - InvalidCidException if the CID is invalid | ||
* @throws - InvalidContentException if the retrieved content is invalid | ||
*/ | ||
getMetadata<T>(ipfsCid: string, validateContent?: z.ZodSchema<T>): Promise<T | undefined>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export * from "./exceptions/index.js"; | ||
export * from "./interfaces/index.js"; | ||
export * from "./utils/index.js"; | ||
export * from "./providers/index.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./ipfs.provider.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import axios, { AxiosInstance } from "axios"; | ||
import { z } from "zod"; | ||
|
||
import type { IMetadataProvider } from "../internal.js"; | ||
import { | ||
EmptyGatewaysUrlsException, | ||
InvalidCidException, | ||
InvalidContentException, | ||
isValidCid, | ||
} from "../internal.js"; | ||
|
||
export class IpfsProvider implements IMetadataProvider { | ||
private readonly axiosInstance: AxiosInstance; | ||
|
||
constructor(private readonly gateways: string[]) { | ||
if (gateways.length === 0) { | ||
throw new EmptyGatewaysUrlsException(); | ||
} | ||
|
||
this.gateways = gateways; | ||
this.axiosInstance = axios.create(); | ||
} | ||
|
||
/* @inheritdoc */ | ||
async getMetadata<T>( | ||
ipfsCid: string, | ||
validateContent?: z.ZodSchema<T>, | ||
): Promise<T | undefined> { | ||
if (!isValidCid(ipfsCid)) { | ||
throw new InvalidCidException(ipfsCid); | ||
} | ||
|
||
for (const gateway of this.gateways) { | ||
const url = `${gateway}/ipfs/${ipfsCid}`; | ||
try { | ||
//TODO: retry policy for each gateway | ||
const { data } = await this.axiosInstance.get<T>(url); | ||
return this.validateData(data, validateContent); | ||
} catch (error: unknown) { | ||
if (error instanceof InvalidContentException) throw error; | ||
|
||
if (axios.isAxiosError(error)) { | ||
console.warn(`Failed to fetch from ${url}: ${error.message}`); | ||
} else { | ||
console.error(`Failed to fetch from ${url}: ${error}`); | ||
} | ||
} | ||
} | ||
|
||
console.error(`Failed to fetch IPFS data for CID ${ipfsCid} from all gateways.`); | ||
return undefined; | ||
Comment on lines
+39
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here we need a tailored error handling
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open question: Could it happen that some content of a cid exist in one gateway and not in other ? In other words, can we be sure that a cid content doesn't exist in some way ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's an interesting question, i have to research since i don't know much about IPFS regarding the first:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tldr; it's possible that a CID "exists" on one gateway and not on another if the content hasn't propagated to the node corresponding to that gateway: |
||
} | ||
|
||
/** | ||
* Validates the data using the provided schema. | ||
* | ||
* @param data - The data to validate. | ||
* @param validateContent (optional) - The schema to validate the data against. | ||
* @returns The validated data. | ||
* @throws InvalidContentException if the data does not match the schema. | ||
*/ | ||
private validateData<T>(data: T, validateContent?: z.ZodSchema<T>): T { | ||
if (validateContent) { | ||
const parsedData = validateContent.safeParse(data); | ||
if (parsedData.success) { | ||
return parsedData.data; | ||
} else { | ||
throw new InvalidContentException( | ||
parsedData.error.issues.map((issue) => JSON.stringify(issue)).join("\n"), | ||
); | ||
} | ||
} | ||
return data; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export const isValidCid = (cid: string): boolean => { | ||
const cidRegex = /^(Qm[1-9A-HJ-NP-Za-km-z]{44}|baf[0-9A-Za-z]{50,})$/; | ||
return cidRegex.test(cid); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import MockAdapter from "axios-mock-adapter"; | ||
import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
import { z } from "zod"; | ||
|
||
import { | ||
EmptyGatewaysUrlsException, | ||
InvalidCidException, | ||
InvalidContentException, | ||
IpfsProvider, | ||
} from "../../src/external.js"; | ||
|
||
describe("IpfsProvider", () => { | ||
let mock: MockAdapter; | ||
let provider: IpfsProvider; | ||
const gateways = ["https://ipfs.io", "https://cloudflare-ipfs.com"]; | ||
const validCid = "QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ"; | ||
|
||
beforeEach(() => { | ||
provider = new IpfsProvider(gateways); | ||
mock = new MockAdapter(provider["axiosInstance"]); | ||
}); | ||
|
||
afterEach(() => { | ||
mock.reset(); | ||
}); | ||
|
||
describe("constructor", () => { | ||
it("throw EmptyGatewaysUrlsException when initialized with empty gateways array", () => { | ||
expect(() => new IpfsProvider([])).toThrow(EmptyGatewaysUrlsException); | ||
}); | ||
}); | ||
|
||
describe("getMetadata", () => { | ||
it("throw InvalidCidException for invalid CID", async () => { | ||
await expect(() => provider.getMetadata("invalid-cid")).rejects.toThrow( | ||
InvalidCidException, | ||
); | ||
}); | ||
|
||
it("fetch metadata successfully from the first working gateway", async () => { | ||
const mockData = { name: "Test Data" }; | ||
mock.onGet(`${gateways[0]}/ipfs/${validCid}`).reply(200, mockData); | ||
|
||
const result = await provider.getMetadata(validCid); | ||
expect(result).toEqual(mockData); | ||
}); | ||
|
||
it("try the next gateway if the first one fails", async () => { | ||
const mockData = { name: "Test Data" }; | ||
mock.onGet(`${gateways[0]}/ipfs/${validCid}`).networkError(); | ||
mock.onGet(`${gateways[1]}/ipfs/${validCid}`).reply(200, mockData); | ||
|
||
const result = await provider.getMetadata(validCid); | ||
expect(result).toEqual(mockData); | ||
}); | ||
|
||
it("return undefined if all gateways fail", async () => { | ||
gateways.forEach((gateway) => { | ||
mock.onGet(`${gateway}/ipfs/${validCid}`).networkError(); | ||
}); | ||
|
||
const result = await provider.getMetadata(validCid); | ||
expect(result).toBeUndefined(); | ||
}); | ||
|
||
it("validate content with provided schema", async () => { | ||
const mockData = { name: "Test Data", age: 30 }; | ||
mock.onGet(`${gateways[0]}/ipfs/${validCid}`).reply(200, mockData); | ||
|
||
const schema = z.object({ | ||
name: z.string(), | ||
age: z.number(), | ||
}); | ||
|
||
const result = await provider.getMetadata(validCid, schema); | ||
expect(result).toEqual(mockData); | ||
}); | ||
|
||
it("throw InvalidContentException when content does not match schema", async () => { | ||
const mockData = { name: "Test Data", age: "thirty" }; | ||
mock.onGet(`${gateways[0]}/ipfs/${validCid}`).reply(200, mockData); | ||
|
||
const schema = z.object({ | ||
name: z.string(), | ||
age: z.number(), | ||
}); | ||
|
||
await expect(() => provider.getMetadata(validCid, schema)).rejects.toThrow( | ||
InvalidContentException, | ||
); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import { describe, expect, it } from "vitest"; | ||
|
||
import { isValidCid } from "../../src/utils/index.js"; | ||
|
||
describe("isValidCid", () => { | ||
it("return true for valid CIDs", () => { | ||
const validCids = [ | ||
"QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ", | ||
"bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", | ||
]; | ||
|
||
validCids.forEach((cid) => { | ||
expect(isValidCid(cid)).toBe(true); | ||
}); | ||
}); | ||
|
||
it("return false for invalid CIDs", () => { | ||
const invalidCids = [ | ||
"", | ||
"QmInvalidCID", | ||
"bafInvalidCID", | ||
"Qm1234567890123456789012345678901234567890123", | ||
"baf123456789012345678901234567890123456789012345678901", | ||
"not a CID at all", | ||
]; | ||
|
||
invalidCids.forEach((cid) => { | ||
expect(isValidCid(cid)).toBe(false); | ||
}); | ||
}); | ||
|
||
it("return false for non-string inputs", () => { | ||
const nonStringInputs = [null, undefined, 123, {}, []]; | ||
|
||
nonStringInputs.forEach((input) => { | ||
expect(isValidCid(input as unknown as string)).toBe(false); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
/* Based on total-typescript no-dom library config */ | ||
/* https://github.com/total-typescript/tsconfig */ | ||
{ | ||
"extends": "../../tsconfig.build.json", | ||
"compilerOptions": { | ||
"composite": true, | ||
"declarationMap": true, | ||
"declaration": true, | ||
"outDir": "dist" | ||
}, | ||
"include": ["src/**/*"], | ||
"exclude": ["node_modules", "dist", "test"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"extends": "../../tsconfig.json", | ||
"include": ["src/**/*", "test/utils/index.test.ts"] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if latency here is super important but, if it is, have you considered using
Promise.any
? You'll always be triggering N requests but resolving with the first successful one, instead of calling them sequentially until one of them succeeds.It could be also helpful to quickly handle non-existant CIDs if, for some reason, you expect working with non-existant CIDs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that could be super helpful but maybe at the same time we exhaust all of the gateways when not needed to 🤔 , like i said to kenji, i will read more about IPFS cuz i know little
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will go back into this analysis when implementing the retry policy, for now we keep this simpler solution for the MVP