Skip to content

Commit

Permalink
Add getPrices API (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukekim authored Mar 13, 2023
1 parent 87ffabf commit c386e06
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 62 deletions.
3 changes: 0 additions & 3 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ jobs:
- run: yarn install
- run: yarn build
- run: |
echo $API_KEY > .env
echo $RELAY_KEY >> .env
echo $RELAY_SECRET >> .env
yarn test
env:
API_KEY: ${{ secrets.API_KEY }}
Expand Down
13 changes: 4 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
env:
API_KEY: ${{ secrets.API_KEY }}
RELAY_KEY: ${{ secrets.RELAY_KEY }}
RELAY_SECRET: ${{ secrets.RELAY_SECRET }}
name: Test on ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
Expand All @@ -32,11 +28,10 @@ jobs:
run: |
yarn install
yarn build
- name: Add secrets to .env
run: |
echo $API_KEY > .env
echo $RELAY_KEY >> .env
echo $RELAY_SECRET >> .env
- name: Running tests
run: |
yarn test
env:
API_KEY: ${{ secrets.API_KEY }}
RELAY_KEY: ${{ secrets.RELAY_KEY }}
RELAY_SECRET: ${{ secrets.RELAY_SECRET }}
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@spiceai/spice",
"description": "Spice.js Node.js SDK",
"version": "0.2.0",
"version": "0.3.0",
"engines": {
"node": ">=18.0.0"
},
Expand All @@ -14,9 +14,9 @@
"author": "Spice AI, Inc. <[email protected]>",
"license": "MIT",
"dependencies": {
"@grpc/grpc-js": "^1.8.4",
"@grpc/proto-loader": "^0.7.4",
"apache-arrow": "^10.0.1"
"@grpc/grpc-js": "^1.8.12",
"@grpc/proto-loader": "^0.7.5",
"apache-arrow": "^11.0.0"
},
"scripts": {
"clean": "rimraf dist",
Expand All @@ -43,4 +43,4 @@
"typescript": "^4.9.4",
"ws": "^8.12.0"
}
}
}
88 changes: 75 additions & 13 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import {
import {
AsyncQueryRequest,
AsyncQueryResponse,
HistoricalPrices,
LatestPrice,
QueryCompleteNotification,
QueryResultsResponse,
} from './interfaces';

const HTTP_DATA_PATH = 'https://data.spiceai.io/v0.1/sql';
const HTTP_DATA_PATH = 'https://data.spiceai.io/';
const FLIGHT_PATH = 'flight.spiceai.io:443';

const PROTO_PATH = './proto/Flight.proto';
Expand Down Expand Up @@ -88,6 +90,55 @@ class SpiceClient {
return client.DoGet(flightTicket);
}

public async getPrice(pair: string): Promise<LatestPrice> {
if (!pair) {
throw new Error('Pair is required');
}

const resp = await this.fetch(`/v0.1/prices/${pair}`);
if (!resp.ok) {
throw new Error(
`Failed to get latest price: ${resp.statusText} (${await resp.text()})`
);
}

return resp.json();
}

public async getPrices(
pair: string,
startTime?: number,
endTime?: number,
granularity?: string
): Promise<HistoricalPrices> {
if (!pair) {
throw new Error('Pair is required');
}

const params: { [key: string]: string } = {
preview: 'true',
};

if (startTime) {
params.start = startTime.toString();
}
if (endTime) {
params.end = endTime.toString();
}
if (granularity) {
params.granularity = granularity;
}

const resp = await this.fetch(`/v0.1/prices/${pair}`, params);
if (!resp.ok) {
throw new Error(
`Failed to get prices: ${resp.statusText} (${await resp.text()})`
);
}

return resp.json();
}

public async query(
queryText: string,
onData: ((data: Table) => void) | undefined = undefined
Expand Down Expand Up @@ -140,7 +191,7 @@ class SpiceClient {
notifications: [{ name: queryName, type: 'webhook', uri: webhookUri }],
};

const resp = await fetch(HTTP_DATA_PATH, {
const resp = await fetch(`${HTTP_DATA_PATH}/v0.1/sql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -177,24 +228,18 @@ class SpiceClient {
);
}

let url = `${HTTP_DATA_PATH}/${queryId}`;
const params: { [key: string]: string } = {};

if (offset || limit) {
if (offset) {
url += `?offset=${offset}`;
params.offset = String(offset);
}
if (limit) {
url += `${offset ? '&' : '?'}limit=${limit}`;
params.limit = String(limit);
}
}

const resp = await fetch(url, {
method: 'GET',
headers: {
'Accept-Encoding': 'br, gzip, deflate',
'X-API-Key': this._apiKey,
},
});

const resp = await this.fetch(`/v0.1/sql/${queryId}`, params);
if (!resp.ok) {
throw new Error(
`Failed to get query results: ${resp.status} ${
Expand Down Expand Up @@ -255,6 +300,23 @@ class SpiceClient {

return await this.getQueryResultsAll(notification.queryId);
}

private fetch = async (path: string, params?: { [key: string]: string }) => {
let url;
if (params && Object.keys(params).length) {
url = `${HTTP_DATA_PATH}/${path}?${new URLSearchParams(params)}`;
} else {
url = `${HTTP_DATA_PATH}/${path}`;
}

return await fetch(url, {
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'br, gzip, deflate',
'X-API-Key': this._apiKey,
},
});
};
}

export { SpiceClient };
15 changes: 15 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,18 @@ export interface QueryResultsResponse {
schema: { name: 'string'; type: { name: string } }[];
rows: any[];
}

export interface HistoricalPrices {
pair: string;
prices: {
timestamp: string;
price: number;
}[];
}

export interface LatestPrice {
pair: string;
minPrice: string;
maxPrice: string;
avePrice: string;
}
66 changes: 57 additions & 9 deletions test/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dotenv from 'dotenv';
import { WebSocket } from 'ws';
import { SpiceClient } from '../';
import 'dotenv/config';
Expand All @@ -7,31 +8,36 @@ import {
AsyncQueryResponse,
QueryCompleteNotification,
} from '../src/interfaces';
import { LatestPrice } from '../dist/interfaces';

const RELAY_BUCKETS = ['spice.js'];
const RELAY_URL = 'https://o4skc7qyx7mrl8x7wdtgmc.hooks.webhookrelay.com';

dotenv.config();
const api_key = process.env.API_KEY;
if (!api_key) {
throw 'API_KEY environment variable not set';
}
const client = new SpiceClient(api_key);

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));

test('streaming works', async () => {
let numChunks = 0;
await client.query(
'SELECT number, "timestamp", base_fee_per_gas, base_fee_per_gas / 1e9 AS base_fee_per_gas_gwei FROM eth.blocks limit 2000',
(table) => {
expect(table.toArray().length).toBeLessThan(2000);
expect(table.toArray().length).toBeLessThanOrEqual(2000);

let baseFeeGwei = table.getChild('base_fee_per_gas_gwei');
expect(baseFeeGwei).toBeTruthy();
baseFeeGwei = baseFeeGwei as Vector;
expect(baseFeeGwei.length).toBeLessThan(2000);
expect(baseFeeGwei.length).toBeLessThanOrEqual(2000);
numChunks++;
}
);
expect(numChunks).toEqual(2);
expect(numChunks).toBeGreaterThanOrEqual(1);
expect(numChunks).toBeLessThanOrEqual(3);
});

test('full result works', async () => {
Expand All @@ -55,19 +61,20 @@ test('async query first page works', async () => {

const webhook = new Promise<void>((resolve) => {
ws = listenForWebhookMessage(RELAY_BUCKETS, async (body: string) => {
ws.close();
await wait(500);

const notification = JSON.parse(body) as QueryCompleteNotification;
if (notification.sql !== queryText) return;

expect(notification.appId).toEqual(49);
expect(notification.appId).toEqual(239); // spicehq/spicejs
expect(notification.queryId).toHaveLength(36);
expect(notification.requestTime).toBeTruthy();
expect(notification.completionTime).toBeTruthy();
expect(notification.state).toEqual('completed');
expect(notification.sql).toEqual(queryText);
expect(notification.rowCount).toEqual(3);

ws.close();

const results = await client.getQueryResultsFromNotification(body);

expect(results.rowCount).toEqual(3);
Expand All @@ -93,7 +100,7 @@ test('async query first page works', async () => {
expect(queryResp.queryId).toHaveLength(36);

await webhook;
});
}, 30000);

test('async query all pages works', async () => {
const rowLimit = 1250;
Expand All @@ -105,11 +112,13 @@ test('async query all pages works', async () => {

const webhook = new Promise<void>((resolve) => {
ws = listenForWebhookMessage(RELAY_BUCKETS, async (body: string) => {
ws.close();
await wait(500);

const notification = JSON.parse(body) as QueryCompleteNotification;
if (notification.sql !== queryText) return;
ws.close();

expect(notification.appId).toEqual(49);
expect(notification.appId).toEqual(239); // spicehq/spicejs
expect(notification.queryId).toHaveLength(36);
expect(notification.state).toEqual('completed');
expect(notification.rowCount).toEqual(rowLimit);
Expand All @@ -129,4 +138,43 @@ test('async query all pages works', async () => {
expect(queryResp.queryId).toHaveLength(36);

await webhook;
}, 30000);

test('test latest prices (USD) works', async () => {
const price = await client.getPrice('BTC');
const latestPrice = price as LatestPrice;

expect(latestPrice).toBeTruthy();
expect(latestPrice.pair).toEqual('BTC-USD');
expect(latestPrice.minPrice).toBeTruthy();
expect(latestPrice.maxPrice).toBeTruthy();
expect(latestPrice.avePrice).toBeTruthy();
});

test('test latest prices (other currency) works', async () => {
const price = await client.getPrice('BTC-AUD');
const latestPrice = price as LatestPrice;

expect(latestPrice).toBeTruthy();
expect(latestPrice.pair).toEqual('BTC-AUD');
expect(latestPrice.minPrice).toBeTruthy();
expect(latestPrice.maxPrice).toBeTruthy();
expect(latestPrice.avePrice).toBeTruthy();
});

test('test historical prices works', async () => {
const prices = await client.getPrices(
'BTC-USD',
new Date('2023-01-01').getTime(),
new Date('2023-01-02').getTime(),
'1h'
);

expect(prices).toBeTruthy();
expect(prices.pair).toEqual('BTC-USD');
expect(prices.prices.length).toEqual(24);
expect(prices.prices[0].timestamp).toEqual('2023-01-01T00:59:00Z');
expect(prices.prices[0].price).toEqual(16539.396678151857);
expect(prices.prices[23].timestamp).toEqual('2023-01-01T23:59:00Z');
expect(prices.prices[23].price).toEqual(16625.08055070908);
});
Loading

0 comments on commit c386e06

Please sign in to comment.