pyth-crosschain/third_party/pyth/price-service/src/rest.ts

256 lines
7.3 KiB
TypeScript

import cors from "cors";
import express, { NextFunction, Request, Response } from "express";
import { Joi, schema, validate, ValidationError } from "express-validation";
import { Server } from "http";
import { StatusCodes } from "http-status-codes";
import morgan from "morgan";
import responseTime from "response-time";
import { DurationInMs, DurationInSec, TimestampInSec } from "./helpers";
import { PriceStore } from "./listen";
import { logger } from "./logging";
import { PromClient } from "./promClient";
import { HexString } from "@pythnetwork/pyth-sdk-js";
const MORGAN_LOG_FORMAT =
':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
' :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 {
private port: number;
private priceFeedVaaInfo: PriceStore;
private isReady: (() => boolean) | undefined;
private promClient: PromClient | undefined;
constructor(
config: { port: number },
priceFeedVaaInfo: PriceStore,
isReady?: () => boolean,
promClient?: PromClient
) {
this.port = config.port;
this.priceFeedVaaInfo = priceFeedVaaInfo;
this.isReady = isReady;
this.promClient = promClient;
}
// Run this function without blocking (`await`) if you want to run it async.
async createApp() {
const app = express();
app.use(cors());
const winstonStream = {
write: (text: string) => {
logger.info(text);
},
};
app.use(morgan(MORGAN_LOG_FORMAT, { stream: winstonStream }));
const endpoints: string[] = [];
const latestVaasInputSchema: schema = {
query: Joi.object({
ids: Joi.array()
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
.required(),
}).required(),
};
app.get(
"/api/latest_vaas",
validate(latestVaasInputSchema),
(req: Request, res: Response) => {
const priceIds = req.query.ids as string[];
// 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.
const vaaMap = new Map<number, Buffer>();
const notFoundIds: string[] = [];
for (let id of priceIds) {
if (id.startsWith("0x")) {
id = id.substring(2);
}
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
if (latestPriceInfo === undefined) {
notFoundIds.push(id);
continue;
}
vaaMap.set(latestPriceInfo.seqNum, latestPriceInfo.vaa);
}
if (notFoundIds.length > 0) {
throw RestException.PriceFeedIdNotFound(notFoundIds);
}
const jsonResponse = Array.from(vaaMap.values(), (vaa) =>
vaa.toString("base64")
);
res.json(jsonResponse);
}
);
endpoints.push(
"api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
);
const latestPriceFeedsInputSchema: schema = {
query: Joi.object({
ids: Joi.array()
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
.required(),
verbose: Joi.boolean(),
}).required(),
};
app.get(
"/api/latest_price_feeds",
validate(latestPriceFeedsInputSchema),
(req: Request, res: Response) => {
const priceIds = req.query.ids as string[];
// verbose is optional, default to false
const verbose = req.query.verbose === "true";
const responseJson = [];
const notFoundIds: string[] = [];
for (let id of priceIds) {
if (id.startsWith("0x")) {
id = id.substring(2);
}
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
if (latestPriceInfo === undefined) {
notFoundIds.push(id);
continue;
}
if (verbose) {
responseJson.push({
...latestPriceInfo.priceFeed.toJson(),
metadata: {
emitter_chain: latestPriceInfo.emitterChainId,
attestation_time: latestPriceInfo.attestationTime,
sequence_number: latestPriceInfo.seqNum,
price_service_receive_time:
latestPriceInfo.priceServiceReceiveTime,
},
});
} else {
responseJson.push(latestPriceInfo.priceFeed.toJson());
}
}
if (notFoundIds.length > 0) {
throw RestException.PriceFeedIdNotFound(notFoundIds);
}
res.json(responseJson);
}
);
endpoints.push(
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
);
endpoints.push(
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true"
);
app.get("/api/price_feed_ids", (req: Request, res: Response) => {
const availableIds = this.priceFeedVaaInfo.getPriceIds();
res.json([...availableIds]);
});
endpoints.push("api/price_feed_ids");
const staleFeedsInputSchema: schema = {
query: Joi.object({
threshold: Joi.number().required(),
}).required(),
};
app.get(
"/api/stale_feeds",
validate(staleFeedsInputSchema),
(req: Request, res: Response) => {
const stalenessThresholdSeconds = Number(req.query.threshold as string);
const currentTime: TimestampInSec = Math.floor(Date.now() / 1000);
const priceIds = [...this.priceFeedVaaInfo.getPriceIds()];
const stalePrices: Record<HexString, number> = {};
for (const priceId of priceIds) {
const latency =
currentTime -
this.priceFeedVaaInfo.getLatestPriceInfo(priceId)!.attestationTime;
if (latency > stalenessThresholdSeconds) {
stalePrices[priceId] = latency;
}
}
res.json(stalePrices);
}
);
endpoints.push("/api/stale_feeds?threshold=<staleness_threshold_seconds>");
app.get("/ready", (_, res: Response) => {
if (this.isReady!()) {
res.sendStatus(StatusCodes.OK);
} else {
res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE);
}
});
endpoints.push("ready");
app.get("/live", (_, res: Response) => {
res.sendStatus(StatusCodes.OK);
});
endpoints.push("live");
// Websocket endpoint
endpoints.push("ws");
app.get("/", (_, res: Response) => res.json(endpoints));
app.use((err: any, _: Request, res: Response, next: NextFunction) => {
if (err instanceof ValidationError) {
return res.status(err.statusCode).json(err);
}
if (err instanceof RestException) {
return res.status(err.statusCode).json(err);
}
return next(err);
});
return app;
}
async run(): Promise<Server> {
const app = await this.createApp();
return app.listen(this.port, () =>
logger.debug("listening on REST port " + this.port)
);
}
}