Refactor Price Service + Add tests (#202)
* Add Rest Exception * Add tests * Update endpoint names and query params
This commit is contained in:
parent
35912e874e
commit
6ce60e5ba6
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
|
@ -4,15 +4,22 @@
|
||||||
"description": "Pyth Price Service",
|
"description": "Pyth Price Service",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node lib/index.js"
|
"start": "node lib/index.js",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^27.5.0",
|
||||||
"@types/long": "^4.0.1",
|
"@types/long": "^4.0.1",
|
||||||
"@types/node": "^16.6.1",
|
"@types/node": "^16.6.1",
|
||||||
|
"@types/supertest": "^2.0.12",
|
||||||
|
"jest": "^28.0.3",
|
||||||
"prettier": "^2.3.2",
|
"prettier": "^2.3.2",
|
||||||
|
"supertest": "^6.2.3",
|
||||||
|
"ts-jest": "^28.0.1",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
"tslint-config-prettier": "^1.18.0",
|
"tslint-config-prettier": "^1.18.0",
|
||||||
"typescript": "^4.3.5"
|
"typescript": "^4.3.5"
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { HexString, PriceFeed, PriceStatus } from "@pythnetwork/pyth-sdk-js";
|
||||||
|
import { PriceFeedPriceInfo, PriceInfo } from "../listen";
|
||||||
|
import {RestAPI} from "../rest"
|
||||||
|
import { Express } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
let app: Express;
|
||||||
|
let priceInfoMap: Map<string, PriceInfo>;
|
||||||
|
|
||||||
|
function expandTo64Len(id: string): string {
|
||||||
|
return id.repeat(64).substring(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dummyPriceFeed(id: string): PriceFeed {
|
||||||
|
return new PriceFeed({
|
||||||
|
conf: "0",
|
||||||
|
emaConf: "1",
|
||||||
|
emaPrice: "2",
|
||||||
|
expo: 4,
|
||||||
|
id,
|
||||||
|
maxNumPublishers: 7,
|
||||||
|
numPublishers: 6,
|
||||||
|
prevConf: "8",
|
||||||
|
prevPrice: "9",
|
||||||
|
prevPublishTime: 10,
|
||||||
|
price: "11",
|
||||||
|
productId: "def456",
|
||||||
|
publishTime: 13,
|
||||||
|
status: PriceStatus.Trading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dummyPriceInfoPair(id: HexString, seqNum: number, vaa: HexString): [HexString, PriceInfo] {
|
||||||
|
return [id, {
|
||||||
|
priceFeed: dummyPriceFeed(id),
|
||||||
|
receiveTime: 0,
|
||||||
|
seqNum,
|
||||||
|
vaaBytes: Buffer.from(vaa, 'hex').toString('binary')
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
priceInfoMap = new Map<string, PriceInfo>([
|
||||||
|
dummyPriceInfoPair(expandTo64Len('abcd'), 1, 'a1b2c3d4'),
|
||||||
|
dummyPriceInfoPair(expandTo64Len('ef01'), 1, 'a1b2c3d4'),
|
||||||
|
dummyPriceInfoPair(expandTo64Len('3456'), 2, 'bad01bad'),
|
||||||
|
dummyPriceInfoPair(expandTo64Len('10101'), 3, 'bidbidbid'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let priceInfo: PriceFeedPriceInfo = {
|
||||||
|
getLatestPriceInfo: (priceFeedId: string) => {
|
||||||
|
return priceInfoMap.get(priceFeedId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new RestAPI(
|
||||||
|
{port: 8889},
|
||||||
|
priceInfo,
|
||||||
|
() => true
|
||||||
|
);
|
||||||
|
|
||||||
|
app = await api.createApp();
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Latest Price Feed Endpoint", () => {
|
||||||
|
test("When called with valid ids, returns correct price feed", async () => {
|
||||||
|
const ids = [expandTo64Len('abcd'), expandTo64Len('3456')];
|
||||||
|
const resp = await request(app).get('/latest_price_feeds').query({ids});
|
||||||
|
expect(resp.status).toBe(StatusCodes.OK);
|
||||||
|
expect(resp.body.length).toBe(2);
|
||||||
|
expect(resp.body).toContainEqual(dummyPriceFeed(ids[0]).toJson());
|
||||||
|
expect(resp.body).toContainEqual(dummyPriceFeed(ids[1]).toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("When called with some non-existant ids within ids, returns error mentioning non-existant ids", async () => {
|
||||||
|
const ids = [expandTo64Len('ab01'), expandTo64Len('3456'), expandTo64Len('effe')];
|
||||||
|
const resp = await request(app).get('/latest_price_feeds').query({ids});
|
||||||
|
expect(resp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||||
|
expect(resp.body.message).toContain(ids[0]);
|
||||||
|
expect(resp.body.message).not.toContain(ids[1]);
|
||||||
|
expect(resp.body.message).toContain(ids[2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Latest Vaa Bytes Endpoint", () => {
|
||||||
|
test("When called with valid ids, returns vaa bytes as array, merged if necessary", async () => {
|
||||||
|
const ids = [expandTo64Len('abcd'), expandTo64Len('ef01'), expandTo64Len('3456')];
|
||||||
|
const resp = await request(app).get('/latest_vaas').query({ids});
|
||||||
|
expect(resp.status).toBe(StatusCodes.OK);
|
||||||
|
expect(resp.body.length).toBe(2);
|
||||||
|
expect(resp.body).toContain(Buffer.from('a1b2c3d4', 'hex').toString('base64'));
|
||||||
|
expect(resp.body).toContain(Buffer.from('bad01bad', 'hex').toString('base64'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("When called with some non-existant ids within ids, returns error mentioning non-existant ids", async () => {
|
||||||
|
const ids = [expandTo64Len('ab01'), expandTo64Len('3456'), expandTo64Len('effe')];
|
||||||
|
const resp = await request(app).get('/latest_vaas').query({ids});
|
||||||
|
expect(resp.status).toBe(StatusCodes.BAD_REQUEST);
|
||||||
|
expect(resp.body.message).toContain(ids[0]);
|
||||||
|
expect(resp.body.message).not.toContain(ids[1]);
|
||||||
|
expect(resp.body.message).toContain(ids[2]);
|
||||||
|
});
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
import * as winston from "winston";
|
import * as winston from "winston";
|
||||||
|
|
||||||
export let logger = winston.createLogger();
|
export let logger = winston.createLogger({transports: [new winston.transports.Console()]});
|
||||||
|
|
||||||
// Logger should be initialized before using logger
|
// Logger should be initialized before using logger
|
||||||
export function initLogger(config?: {logLevel?: string}) {
|
export function initLogger(config?: {logLevel?: string}) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import express from "express";
|
import express, {Express} from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
import responseTime from "response-time";
|
import responseTime from "response-time";
|
||||||
|
@ -13,6 +13,20 @@ import { validate, ValidationError, Joi, schema } from "express-validation";
|
||||||
const MORGAN_LOG_FORMAT = ':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
|
const MORGAN_LOG_FORMAT = ':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
|
||||||
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"';
|
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"';
|
||||||
|
|
||||||
|
export class RestException extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
message: string;
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
static PriceFeedIdNotFound(notFoundIds: string[]): RestException {
|
||||||
|
return new RestException(StatusCodes.BAD_REQUEST, `Price Feeds with ids ${notFoundIds.join(', ')} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RestAPI {
|
export class RestAPI {
|
||||||
private port: number;
|
private port: number;
|
||||||
private priceFeedVaaInfo: PriceFeedPriceInfo;
|
private priceFeedVaaInfo: PriceFeedPriceInfo;
|
||||||
|
@ -30,7 +44,7 @@ export class RestAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run this function without blocking (`await`) if you want to run it async.
|
// Run this function without blocking (`await`) if you want to run it async.
|
||||||
async run() {
|
async createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
|
@ -48,19 +62,15 @@ export class RestAPI {
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.listen(this.port, () =>
|
|
||||||
logger.debug("listening on REST port " + this.port)
|
|
||||||
);
|
|
||||||
|
|
||||||
let endpoints: string[] = [];
|
let endpoints: string[] = [];
|
||||||
|
|
||||||
const latestVaaBytesInputSchema: schema = {
|
const latestVaasInputSchema: schema = {
|
||||||
query: Joi.object({
|
query: Joi.object({
|
||||||
id: Joi.array().items(Joi.string().regex(/^[a-f0-9]{64}$/))
|
ids: Joi.array().items(Joi.string().regex(/^[a-f0-9]{64}$/))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
app.get("/latest_vaa_bytes", validate(latestVaaBytesInputSchema), (req: Request, res: Response) => {
|
app.get("/latest_vaas", validate(latestVaasInputSchema), (req: Request, res: Response) => {
|
||||||
let priceIds = req.query.id as string[];
|
let priceIds = req.query.ids as string[];
|
||||||
|
|
||||||
// Multiple price ids might share same vaa, we use sequence number as
|
// Multiple price ids might share same vaa, we use sequence number as
|
||||||
// key of a vaa and deduplicate using a map of seqnum to vaa bytes.
|
// key of a vaa and deduplicate using a map of seqnum to vaa bytes.
|
||||||
|
@ -83,8 +93,7 @@ export class RestAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notFoundIds.length > 0) {
|
if (notFoundIds.length > 0) {
|
||||||
res.status(StatusCodes.BAD_REQUEST).send(`Price Feeds with ids ${notFoundIds.join(', ')} not found`);
|
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = Array.from(vaaMap.values(),
|
const jsonResponse = Array.from(vaaMap.values(),
|
||||||
|
@ -93,15 +102,15 @@ export class RestAPI {
|
||||||
|
|
||||||
res.json(jsonResponse);
|
res.json(jsonResponse);
|
||||||
});
|
});
|
||||||
endpoints.push("latest_vaa_bytes?id[]=<price_feed_id>&id[]=<price_feed_id_2>&..");
|
endpoints.push("latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..");
|
||||||
|
|
||||||
const latestPriceFeedInputSchema: schema = {
|
const latestPriceFeedsInputSchema: schema = {
|
||||||
query: Joi.object({
|
query: Joi.object({
|
||||||
id: Joi.array().items(Joi.string().regex(/^[a-f0-9]{64}$/))
|
ids: Joi.array().items(Joi.string().regex(/^[a-f0-9]{64}$/))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
app.get("/latest_price_feed", validate(latestPriceFeedInputSchema), (req: Request, res: Response) => {
|
app.get("/latest_price_feeds", validate(latestPriceFeedsInputSchema), (req: Request, res: Response) => {
|
||||||
let priceIds = req.query.id as string[];
|
let priceIds = req.query.ids as string[];
|
||||||
|
|
||||||
let responseJson = [];
|
let responseJson = [];
|
||||||
|
|
||||||
|
@ -122,13 +131,12 @@ export class RestAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notFoundIds.length > 0) {
|
if (notFoundIds.length > 0) {
|
||||||
res.status(StatusCodes.BAD_REQUEST).send(`Price Feeds with ids ${notFoundIds.join(', ')} not found`);
|
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(responseJson);
|
res.json(responseJson);
|
||||||
});
|
});
|
||||||
endpoints.push("latest_price_feed?id[]=<price_feed_id>&id[]=<price_feed_id_2>&..");
|
endpoints.push("latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..");
|
||||||
|
|
||||||
|
|
||||||
app.get("/ready", (_, res: Response) => {
|
app.get("/ready", (_, res: Response) => {
|
||||||
|
@ -154,8 +162,21 @@ export class RestAPI {
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
return res.status(err.statusCode).json(err);
|
return res.status(err.statusCode).json(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err instanceof RestException) {
|
||||||
|
return res.status(err.statusCode).json(err);
|
||||||
|
}
|
||||||
|
|
||||||
return next(err);
|
return next(err);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
let app = await this.createApp();
|
||||||
|
app.listen(this.port, () =>
|
||||||
|
logger.debug("listening on REST port " + this.port)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue