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 all commits
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
11 changes: 9 additions & 2 deletions src/core/getFrameAccountAddress.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { getFrameAccountAddress } from './getFrameAccountAddress';
import { mockNeynarResponse } from './mock';
import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions';

jest.mock('../utils/neynar/user/neynarUserFunctions', () => {
return {
neynarBulkUserLookup: jest.fn(),
};
});

jest.mock('@farcaster/hub-nodejs', () => {
return {
Expand All @@ -23,7 +30,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, neynarBulkUserLookup as jest.Mock);

const response = await getFrameAccountAddress(fakeFrameData, fakeApiKey);
expect(response).toEqual(addresses[0]);
Expand All @@ -32,7 +39,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, neynarBulkUserLookup as jest.Mock);
validateMock.mockClear();
validateMock.mockResolvedValue({
isOk: () => {
Expand Down
14 changes: 4 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 { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions';

type FidResponse = {
verifications: string[];
Expand Down Expand Up @@ -26,16 +27,9 @@ 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 bulkUserLookupResponse = await neynarBulkUserLookup([farcasterID]);
if (bulkUserLookupResponse?.users) {
const userVerifications = bulkUserLookupResponse?.users[0] as FidResponse;
if (userVerifications.verifications) {
return userVerifications.verifications[0];
}
Expand Down
11 changes: 9 additions & 2 deletions src/core/getFrameValidatedMessage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { mockNeynarResponse } from './mock';
import { getFrameValidatedMessage } from './getFrameValidatedMessage';
import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions';

jest.mock('../utils/neynar/user/neynarUserFunctions', () => {
return {
neynarBulkUserLookup: jest.fn(),
};
});

jest.mock('@farcaster/hub-nodejs', () => {
return {
Expand All @@ -16,7 +23,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, neynarBulkUserLookup as jest.Mock);
validateMock.mockClear();
validateMock.mockResolvedValue({
isOk: () => {
Expand All @@ -32,7 +39,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, neynarBulkUserLookup as jest.Mock);
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/utils/neynar/exceptions/FetchError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class FetchError extends Error {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. for next PR, we can probably have Fetch Error shared in a utils/network folder internal utils.

constructor(message: string) {
super(message);
this.name = 'FetchError';
}
}
12 changes: 12 additions & 0 deletions src/utils/neynar/neynar.integ.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { neynarBulkUserLookup } from './user/neynarUserFunctions';

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

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 resp = await neynarBulkUserLookup([1]);
expect(resp?.users[0]?.fid).toEqual(1);
});

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

export const NEYNAR_DEFAULT_API_KEY = 'NEYNAR_API_DOCS';
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 async function neynarBulkUserLookup(
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. for follow up PR, let's have more docs in here to explain what's going on.

Also let's try to keep the internal architecture as flat as possible, so that instead of having src/utils/neynar/user/neynarUserFunctions.ts we just have src/utils/neynar/neynarUserFunctions.ts as it will make it easier to understand for folks reading and contributing back.

farcasterIDs: number[],
apiKey: string = NEYNAR_DEFAULT_API_KEY,
): 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: 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 convertToNeynarResponseModel(responseBody);
}

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

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

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

function 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