diff --git a/package.json b/package.json index fd420c583..9b9f29364 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snapshot-labs/snapshot.js", - "version": "0.11.22", + "version": "0.11.23", "repository": "snapshot-labs/snapshot.js", "license": "MIT", "main": "dist/snapshot.cjs.js", diff --git a/src/utils.spec.js b/src/utils.spec.js index 25c3bc9ef..b33648b27 100644 --- a/src/utils.spec.js +++ b/src/utils.spec.js @@ -78,13 +78,14 @@ describe('utils', () => { describe('when passing valid args', () => { test('send a JSON-RPC payload to score-api', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_validate({})).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://score.snapshot.org', + 'https://score.snapshot.org/', expect.objectContaining({ body: JSON.stringify({ jsonrpc: '2.0', @@ -96,13 +97,14 @@ describe('utils', () => { }); test('send a POST request with JSON content-type', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_validate({})).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://score.snapshot.org', + 'https://score.snapshot.org/', expect.objectContaining({ method: 'POST', headers: { @@ -114,23 +116,28 @@ describe('utils', () => { }); test('can customize the score-api url', () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect( _validate({ options: { url: 'https://snapshot.org/?apiKey=xxx' } }) ).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://snapshot.org/?apiKey=xxx', - expect.anything() + 'https://snapshot.org/', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-API-KEY': 'xxx' + }) + }) ); }); test('returns the JSON-RPC result property', () => { const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve(result)) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_validate({})).resolves.toEqual('OK'); @@ -141,7 +148,7 @@ describe('utils', () => { test('rejects with the JSON-RPC error object', () => { const result = { error: { message: 'Oh no' } }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve(result)) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_validate({})).rejects.toEqual(result.error); @@ -152,7 +159,7 @@ describe('utils', () => { test('rejects with the error', () => { const result = new Error('Oh no'); fetch.mockReturnValue({ - json: () => { + text: () => { throw result; } }); @@ -196,7 +203,7 @@ describe('utils', () => { network ?? payload.network, addresses ?? payload.addresses, snapshot ?? payload.snapshot, - scoreApiUrl ?? 'https://score.snapshot.org', + scoreApiUrl ?? 'https://score.snapshot.org/', options ?? {} ); } @@ -239,8 +246,9 @@ describe('utils', () => { describe('when passing valid args', () => { test('send a JSON-RPC payload to score-api', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getScores({})).resolves; @@ -253,8 +261,9 @@ describe('utils', () => { }); test('send a POST request with JSON content-type', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getScores({})).resolves; @@ -270,23 +279,29 @@ describe('utils', () => { ); }); - test('can customize the score-api url', () => { + test('can customize the score-api url and if apiKey should be passed in headers', () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getScores({ scoreApiUrl: 'https://snapshot.org/?apiKey=xxx' })) .resolves; expect(fetch).toHaveBeenCalledWith( - 'https://snapshot.org/api/scores?apiKey=xxx', - expect.anything() + 'https://snapshot.org/api/scores', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-API-KEY': 'xxx' + }) + }) ); }); test('returns the JSON-RPC result scores property', () => { const result = { scores: 'SCORES', other: 'Other' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result })) + text: () => + new Promise((resolve) => resolve(JSON.stringify({ result }))) }); expect(_getScores({})).resolves.toEqual('SCORES'); @@ -295,7 +310,8 @@ describe('utils', () => { test('returns the JSON-RPC all properties', () => { const result = { scores: 'SCORES', other: 'Other' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result })) + text: () => + new Promise((resolve) => resolve(JSON.stringify({ result }))) }); expect( @@ -308,7 +324,7 @@ describe('utils', () => { test('rejects with the JSON-RPC error object', () => { const result = { error: { message: 'Oh no' } }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve(result)) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getScores({})).rejects.toEqual(result.error); @@ -319,7 +335,7 @@ describe('utils', () => { test('rejects with the error', () => { const result = new Error('Oh no'); fetch.mockReturnValue({ - json: () => { + text: () => { throw result; } }); @@ -391,13 +407,14 @@ describe('utils', () => { describe('when passing valid args', () => { test('send a JSON-RPC payload to score-api', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getVp({})).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://score.snapshot.org', + 'https://score.snapshot.org/', expect.objectContaining({ body: JSON.stringify({ jsonrpc: '2.0', @@ -409,13 +426,14 @@ describe('utils', () => { }); test('send a POST request with JSON content-type', async () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getVp({})).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://score.snapshot.org', + 'https://score.snapshot.org/', expect.objectContaining({ method: 'POST', headers: { @@ -427,13 +445,14 @@ describe('utils', () => { }); test('can customize the score-api url', () => { + const result = { result: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result: 'OK' })) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getVp({ options: { url: 'https://snapshot.org' } })).resolves; expect(fetch).toHaveBeenCalledWith( - 'https://snapshot.org', + 'https://snapshot.org/', expect.anything() ); }); @@ -441,7 +460,8 @@ describe('utils', () => { test('returns the JSON-RPC result property', () => { const result = { data: 'OK' }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve({ result })) + text: () => + new Promise((resolve) => resolve(JSON.stringify({ result }))) }); expect(_getVp({})).resolves.toEqual(result); @@ -452,7 +472,7 @@ describe('utils', () => { test('rejects with the JSON-RPC error object', () => { const result = { error: { message: 'Oh no' } }; fetch.mockReturnValue({ - json: () => new Promise((resolve) => resolve(result)) + text: () => new Promise((resolve) => resolve(JSON.stringify(result))) }); expect(_getVp({})).rejects.toEqual(result.error); @@ -463,7 +483,7 @@ describe('utils', () => { test('rejects with the error', () => { const result = new Error('Oh no'); fetch.mockReturnValue({ - json: () => { + text: () => { throw result; } }); diff --git a/src/utils.ts b/src/utils.ts index 98adddfee..111220845 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,6 +20,7 @@ import getDelegatesBySpace, { SNAPSHOT_SUBGRAPH_URL } from './utils/delegation'; interface Options { url?: string; + headers?: any; } interface Strategy { @@ -38,6 +39,43 @@ const scoreApiHeaders = { 'Content-Type': 'application/json' }; +const DEFAULT_SCORE_API_URL = 'https://score.snapshot.org'; + +function formatScoreAPIUrl( + url = DEFAULT_SCORE_API_URL, + options = { + path: '' + } +) { + const scoreURL = new URL(url); + if (options.path) scoreURL.pathname = options.path; + const apiKey = scoreURL.searchParams.get('apiKey'); + let headers: any = { ...scoreApiHeaders }; + if (apiKey) { + scoreURL.searchParams.delete('apiKey'); + headers = { ...scoreApiHeaders, 'X-API-KEY': apiKey }; + } + return { + url: scoreURL.toString(), + headers + }; +} + +async function parseScoreAPIResponse(res: any) { + let data: any = await res.text(); + try { + data = JSON.parse(data); + } catch (e) { + return Promise.reject({ + code: res.status || 500, + message: 'Failed to parse response from score API', + data + }); + } + if (data.error) return Promise.reject(data.error); + return data; +} + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, @@ -135,7 +173,6 @@ ajv.addKeyword({ errors: true }); - // Custom URL format to allow empty string values // https://github.com/snapshot-labs/snapshot.js/pull/541/files ajv.addFormat('customUrl', { @@ -287,7 +324,7 @@ export async function getScores( network: string, addresses: string[], snapshot: number | string = 'latest', - scoreApiUrl = 'https://score.snapshot.org', + scoreApiUrl = DEFAULT_SCORE_API_URL, options: any = {} ) { if (!Array.isArray(addresses)) { @@ -317,9 +354,11 @@ export async function getScores( ); } - const url = new URL(scoreApiUrl); - url.pathname = '/api/scores'; - scoreApiUrl = url.toString(); + const urlObject = new URL(scoreApiUrl); + urlObject.pathname = '/api/scores'; + const { url, headers } = formatScoreAPIUrl(scoreApiUrl, { + path: '/api/scores' + }); try { const params = { @@ -329,20 +368,16 @@ export async function getScores( strategies, addresses }; - const res = await fetch(scoreApiUrl, { + const res = await fetch(url, { method: 'POST', - headers: scoreApiHeaders, + headers, body: JSON.stringify({ params }) }); - const obj = await res.json(); - - if (obj.error) { - return Promise.reject(obj.error); - } + const response = await parseScoreAPIResponse(res); return options.returnValue === 'all' - ? obj.result - : obj.result[options.returnValue || 'scores']; + ? response.result + : response.result[options.returnValue || 'scores']; } catch (e) { if (e.errno) { return Promise.reject({ code: e.errno, message: e.toString(), data: '' }); @@ -360,8 +395,7 @@ export async function getVp( delegation: boolean, options?: Options ) { - if (!options) options = {}; - if (!options.url) options.url = 'https://score.snapshot.org'; + const { url, headers } = formatScoreAPIUrl(options?.url); if (!isValidAddress(address)) { return inputError(`Invalid voter address: ${address}`); } @@ -385,7 +419,7 @@ export async function getVp( const init = { method: 'POST', - headers: scoreApiHeaders, + headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'get_vp', @@ -401,10 +435,9 @@ export async function getVp( }; try { - const res = await fetch(options.url, init); - const json = await res.json(); - if (json.error) return Promise.reject(json.error); - if (json.result) return json.result; + const res = await fetch(url, init); + const response = await parseScoreAPIResponse(res); + return response.result; } catch (e) { if (e.errno) { return Promise.reject({ code: e.errno, message: e.toString(), data: '' }); @@ -420,7 +453,7 @@ export async function validate( network: string, snapshot: number | 'latest', params: any, - options: any + options?: Options ) { if (!isValidAddress(author)) { return inputError(`Invalid author: ${author}`); @@ -436,10 +469,11 @@ export async function validate( } if (!options) options = {}; - if (!options.url) options.url = 'https://score.snapshot.org'; + const { url, headers } = formatScoreAPIUrl(options.url); + const init = { method: 'POST', - headers: scoreApiHeaders, + headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'validate', @@ -455,10 +489,9 @@ export async function validate( }; try { - const res = await fetch(options.url, init); - const json = await res.json(); - if (json.error) return Promise.reject(json.error); - return json.result; + const res = await fetch(url, init); + const response = await parseScoreAPIResponse(res); + return response.result; } catch (e) { if (e.errno) { return Promise.reject({ code: e.errno, message: e.toString(), data: '' }); diff --git a/test/e2e/utils/getScores.spec.ts b/test/e2e/utils/getScores.spec.ts index b806edbf6..e7a03beba 100644 --- a/test/e2e/utils/getScores.spec.ts +++ b/test/e2e/utils/getScores.spec.ts @@ -29,4 +29,23 @@ describe('test getScores', () => { data: '' }); }); + + test('getScores should not pass API Key if it is passed in URL', async () => { + expect.assertions(1); + await expect( + getScores( + 'test.eth', + [], + '1', + ['0x9e8f6CF284Db7a80646D9d322A37b3dAF461F8DD'], + 'latest', + 'https://score-null.snapshot.org?apiKey=123' + ) + ).to.rejects.toEqual({ + code: 'ENOTFOUND', + message: + 'FetchError: request to https://score-null.snapshot.org/api/scores failed, reason: getaddrinfo ENOTFOUND score-null.snapshot.org', + data: '' + }); + }); });