Skip to content
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

fix: handle neynar responses + add additional information #35

Merged
merged 3 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testMatch: ['**/?(*.)+(spec|test).ts'],
testMatch: ['**/?(*.)+(spec|test|integ).ts'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, is integ the new way folks call e2e?
I am not agaist that, I am just curios as I never saw this prefix before.
cc @robpolak @cnasc

};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
"format": "prettier --log-level warn --write .",
"format:check": "prettier --check .",
"prebuild": "rimraf dist",
"test": "jest .",
"test:coverage": "jest . --coverage",
"test": "jest --testPathIgnorePatterns=\\.integ\\.",
"test:integration": "jest --testPathIgnorePatterns=\\.test\\.",
"test:coverage": "jest . --coverage ",
"release:check": "changeset status --verbose --since=origin/main",
"release:publish": "yarn install && yarn build && changeset publish",
"release:version": "changeset version && yarn install --immutable"
Expand Down
19 changes: 17 additions & 2 deletions src/core/getFrameAccountAddress.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { getFrameAccountAddress } from './getFrameAccountAddress';
import { mockNeynarResponse } from './mock';

const bulkUserLookupMock = jest.fn();
jest.mock('../internal/neynar/neynarClient', () => {
return {
NeynarClient: jest.fn().mockImplementation(() => {
return {
user: {
bulkUserLookup: bulkUserLookupMock,
// other user functions can be mocked here
},
// other properties and methods of NeynarClient can be mocked here
};
}),
};
});

jest.mock('@farcaster/hub-nodejs', () => {
return {
getSSLHubRpcClient: jest.fn().mockReturnValue({
Expand All @@ -23,7 +38,7 @@ describe('getFrameAccountAddress', () => {
it('should return the first verification for valid input', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
mockNeynarResponse(fid, addresses);
mockNeynarResponse(fid, addresses, bulkUserLookupMock);

const response = await getFrameAccountAddress(fakeFrameData, fakeApiKey);
expect(response).toEqual(addresses[0]);
Expand All @@ -32,7 +47,7 @@ describe('getFrameAccountAddress', () => {
it('when the call from farcaster fails we should return undefined', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
const { validateMock } = mockNeynarResponse(fid, addresses);
const { validateMock } = mockNeynarResponse(fid, addresses, bulkUserLookupMock);
validateMock.mockClear();
validateMock.mockResolvedValue({
isOk: () => {
Expand Down
15 changes: 5 additions & 10 deletions src/core/getFrameAccountAddress.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getFrameValidatedMessage } from './getFrameValidatedMessage';
import { NeynarClient } from '../internal/neynar/neynarClient';

type FidResponse = {
verifications: string[];
Expand Down Expand Up @@ -26,16 +27,10 @@ async function getFrameAccountAddress(
// Get the Farcaster ID from the message
const farcasterID = validatedMessage?.data?.fid ?? 0;
// Get the user verifications from the Farcaster Indexer
const options = {
method: 'GET',
url: `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterID}`,
headers: { accept: 'application/json', api_key: NEYNAR_API_KEY },
};
const resp = await fetch(options.url, { headers: options.headers });
const responseBody = await resp.json();
// Get the user verifications from the response
if (responseBody.users) {
const userVerifications = responseBody.users[0] as FidResponse;
const neynarClient = new NeynarClient();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should create a new instance of NeynarClient() everytime getFrameAccountAddress is called.

So far it seems we are doing:

  • someone might use getFrameAccountAddress multiple times
  • each time a new neynarClient instance is created from NeynarClient
  • each time a new this.user instance is created from NeynarUserFunction(apiKey);

So my personal feeling after reading this three times, I think we should avoid using Classes just because:

  1. anytime new is run in JS it has some hidden memory and cpu cost that I would like to avoid from our side
  2. all the data generated it feels gated inside an instance
  3. the user will abuse our funtion, so every small code weakness will become extra big publically, so beacuse 1 and 2, this feels a bit worry to me.

@robpolak would you be ok to rewrite this as Functional-oriented instead of Class-oriented?
Also as mention over our sync, let's use utils as folder instead of internals.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classes have been purged.. it does make testing much easier.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dope, reading the PR now

const bulkUserLookupResponse = await neynarClient.user.bulkUserLookup([farcasterID]);
if (bulkUserLookupResponse?.users) {
const userVerifications = bulkUserLookupResponse?.users[0] as FidResponse;
if (userVerifications.verifications) {
return userVerifications.verifications[0];
}
Expand Down
19 changes: 17 additions & 2 deletions src/core/getFrameValidatedMessage.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { mockNeynarResponse } from './mock';
import { getFrameValidatedMessage } from './getFrameValidatedMessage';

const bulkUserLookupMock = jest.fn();
jest.mock('../internal/neynar/neynarClient', () => {
return {
NeynarClient: jest.fn().mockImplementation(() => {
return {
user: {
bulkUserLookup: bulkUserLookupMock,
// other user functions can be mocked here
},
// other properties and methods of NeynarClient can be mocked here
};
}),
};
});

jest.mock('@farcaster/hub-nodejs', () => {
return {
getSSLHubRpcClient: jest.fn().mockReturnValue({
Expand All @@ -16,7 +31,7 @@ describe('getFrameValidatedMessage', () => {
it('should return undefined if the message is invalid', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
const { validateMock } = mockNeynarResponse(fid, addresses);
const { validateMock } = mockNeynarResponse(fid, addresses, bulkUserLookupMock);
validateMock.mockClear();
validateMock.mockResolvedValue({
isOk: () => {
Expand All @@ -32,7 +47,7 @@ describe('getFrameValidatedMessage', () => {
it('should return the message if the message is valid', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
mockNeynarResponse(fid, addresses);
mockNeynarResponse(fid, addresses, bulkUserLookupMock);
const fakeFrameData = {
trustedData: {},
};
Expand Down
10 changes: 3 additions & 7 deletions src/core/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ export function buildFarcasterResponse(fid: number) {
};
}

export function mockNeynarResponse(fid: number, addresses: string[]) {
export function mockNeynarResponse(fid: number, addresses: string[], lookupMock: jest.Mock) {
const neynarResponse = {
users: [
{
verifications: addresses,
},
],
};
lookupMock.mockResolvedValue(neynarResponse);

const getSSLHubRpcClientMock = require('@farcaster/hub-nodejs').getSSLHubRpcClient;
const validateMock = getSSLHubRpcClientMock().validateMessage as jest.Mock;
Expand All @@ -33,12 +34,7 @@ export function mockNeynarResponse(fid: number, addresses: string[]) {
});

validateMock.mockResolvedValue(buildFarcasterResponse(fid));
// Mock the response from Neynar
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(neynarResponse),
}),
) as jest.Mock;

return {
validateMock,
};
Expand Down
6 changes: 6 additions & 0 deletions src/internal/neynar/exceptions/FetchError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class FetchError extends Error {
constructor(message: string) {
super(message);
this.name = 'FetchError';
}
}
14 changes: 14 additions & 0 deletions src/internal/neynar/neynar.integ.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NeynarClient } from './neynarClient';

describe('integration tests', () => {
const neynarClient = new NeynarClient();

it('bulk data lookup should find all users', async () => {
const fidsToLookup = [3, 194519]; // dwr and polak.eth fids
const response = await neynarClient.user.bulkUserLookup(fidsToLookup);
expect(response?.users.length).toEqual(2);
for (const user of response?.users!) {
expect(fidsToLookup).toContain(user.fid);
}
});
});
14 changes: 14 additions & 0 deletions src/internal/neynar/neynarClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NeynarUserFunction } from './user/neynarUserFunctions';

export class NeynarClient {
private apiKey;

public user: NeynarUserFunction;
constructor(apiKey = 'NEYNAR_API_DOCS') {
this.apiKey = apiKey;

// Import various function types at construction to have better readability and
// composability of our interfaces.
this.user = new NeynarUserFunction(apiKey);
}
}
33 changes: 33 additions & 0 deletions src/internal/neynar/user/neynarUserFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NeynarClient } from '../neynarClient';
import { FetchError } from '../exceptions/FetchError';

describe('neynar user functions', () => {
let fetchMock = jest.fn();
let status = 200;

beforeEach(() => {
status = 200;
global.fetch = jest.fn(() =>
Promise.resolve({
status,
json: fetchMock,
}),
) as jest.Mock;
});

it('should return fetch response correctly', async () => {
fetchMock.mockResolvedValue({
users: [{ fid: 1 }],
});
const client = new NeynarClient();
const resp = await client.user.bulkUserLookup([1]);
expect(resp?.users[0]?.fid).toEqual(1);
});

it('fails on a non-200', async () => {
status = 401;
const client = new NeynarClient();
const resp = client.user.bulkUserLookup([1]);
await expect(resp).rejects.toThrow(FetchError);
});
});
78 changes: 78 additions & 0 deletions src/internal/neynar/user/neynarUserFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { FetchError } from '../exceptions/FetchError';

export interface NeynarUserModel {
fid: number;
custody_address: string;
username: string;
display_name: string;
pfp_url: string;
profile: {
bio: {
text: string;
};
};
follower_count: number;
verifications: string[];
}
export interface NeynarBulkUserLookupModel {
users: NeynarUserModel[];
}

export class NeynarUserFunction {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ: would we be better off with the official SDK? Implementing our own client on top of their HTTP api is more work than we might want to do over the long run.

One con to using the SDK would be that the bundlesize is a bit high: https://bundlephobia.com/package/@neynar/[email protected] though because this would be running server-side it's not as big a deal cc @Zizzamia

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be open to it.. looks like most of the library is auto-generated.. I wanted to keep our imports lightweight, but it is a great question on future support / extensibility.

private apiKey;
constructor(apiKey = 'NEYNAR_API_DOCS') {
this.apiKey = apiKey;
}
async bulkUserLookup(farcasterIDs: number[]): Promise<NeynarBulkUserLookupModel | undefined> {
const options = {
method: 'GET',
url: `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterIDs.join(',')}`,
headers: { accept: 'application/json', api_key: this.apiKey },
};
const resp = await fetch(options.url, { headers: options.headers });
if (resp.status !== 200) {
throw new FetchError(`non-200 status returned from neynar : ${resp.status}`);
}
const responseBody = await resp.json();
return this.convertToNeynarResponseModel(responseBody);
}

private convertToNeynarResponseModel(data: any): NeynarBulkUserLookupModel | undefined {
if (!data) {
return;
}

const response: NeynarBulkUserLookupModel = {
users: [],
};

for (const user of data.users) {
const formattedUser = this.convertToNeynarUserModel(user);
if (formattedUser) {
response.users.push(formattedUser);
}
}
return response;
}

private convertToNeynarUserModel(data: any): NeynarUserModel | undefined {
if (!data) {
return;
}

return {
fid: data.fid ?? 0,
custody_address: data.custody_address ?? '',
username: data.username ?? '',
display_name: data.display_name ?? '',
pfp_url: data.pfp_url ?? '',
profile: {
bio: {
text: data.profile?.bio?.text ?? '',
},
},
follower_count: data.follower_count ?? 0,
verifications: Array.isArray(data.verifications) ? data.verifications : [],
};
}
}
Loading