diff --git a/src/container.ts b/src/container.ts index fb96f38..7a91d83 100644 --- a/src/container.ts +++ b/src/container.ts @@ -44,6 +44,7 @@ import { } from './services/GiantSquid'; import { BluezNftService, INftService } from './services/NftService'; import { NftController } from './controllers/NftController'; +import { TokenStatsControllerV2 } from './controllers/TokenStatsControllerV2'; const container = new Container(); @@ -137,6 +138,7 @@ container.bind(CallNameMapping.batch).to(BatchCallParser).inSinglet // controllers registration container.bind(ContainerTypes.Controller).to(TokenStatsController); +container.bind(ContainerTypes.Controller).to(TokenStatsControllerV2); container.bind(ContainerTypes.Controller).to(DappsStakingController); container.bind(ContainerTypes.Controller).to(DappsStakingV3Controller); container.bind(ContainerTypes.Controller).to(NodeController); diff --git a/src/controllers/TokenStatsControllerV2.ts b/src/controllers/TokenStatsControllerV2.ts new file mode 100644 index 0000000..f0c9ae9 --- /dev/null +++ b/src/controllers/TokenStatsControllerV2.ts @@ -0,0 +1,43 @@ +import express, { Request, Response } from 'express'; +import { injectable, inject } from 'inversify'; +import { ContainerTypes } from '../containertypes'; +import { IPriceProvider } from '../services/IPriceProvider'; +import { IStatsIndexerService } from '../services/StatsIndexerService'; +import { IStatsService } from '../services/StatsService'; +import { ControllerBase } from './ControllerBase'; +import { IControllerBase } from './IControllerBase'; + +@injectable() +export class TokenStatsControllerV2 extends ControllerBase implements IControllerBase { + constructor( + @inject(ContainerTypes.StatsService) private _statsService: IStatsService, + @inject(ContainerTypes.StatsIndexerService) private _indexerService: IStatsIndexerService, + @inject(ContainerTypes.PriceProviderWithFailover) private _priceProvider: IPriceProvider, + ) { + super(); + } + + public register(app: express.Application): void { + /** + * @description Token current price route v2. + */ + app.route('/api/v2/token/price/:symbol').get(async (req: Request, res: Response) => { + /* + #swagger.description = 'Retrieves current token price with timestamp' + #swagger.tags = ['Token'] + #swagger.parameters['symbol'] = { + in: 'path', + description: 'Token symbol (eg. ASTR or SDN)', + required: true, + enum: ['ASTR', 'SDN'] + } + */ + try { + const currency = req.query.currency as string | undefined; + res.json(await this._priceProvider.getPriceWithTimestamp(req.params.symbol, currency)); + } catch (err) { + this.handleError(res, err as Error); + } + }); + } +} diff --git a/src/services/CacheService.ts b/src/services/CacheService.ts index fa7e8f1..6708e92 100644 --- a/src/services/CacheService.ts +++ b/src/services/CacheService.ts @@ -10,7 +10,7 @@ export class CacheService { constructor(private cachedItemValidityTimeMs: number = 60000) {} - public getItem(key: string): T | undefined { + public getItem(key: string): CacheItem | undefined{ Guard.ThrowIfUndefined('key', key); const cacheItem = this.cache.get(key); @@ -19,12 +19,7 @@ export class CacheService { return undefined; } - if (this.isExpired(cacheItem)) { - this.cache.delete(key); - return undefined; - } - - return cacheItem.data; + return cacheItem; } public setItem(key: string, item: T): void { @@ -37,7 +32,7 @@ export class CacheService { }); } - private isExpired(cacheItem: CacheItem): boolean { + public isExpired(cacheItem: CacheItem): boolean { return cacheItem.updatedAt + this.cachedItemValidityTimeMs < Date.now(); } } diff --git a/src/services/CoinGeckoPriceProvider.ts b/src/services/CoinGeckoPriceProvider.ts index 949664e..f7e4f1a 100644 --- a/src/services/CoinGeckoPriceProvider.ts +++ b/src/services/CoinGeckoPriceProvider.ts @@ -1,6 +1,6 @@ import axios, { AxiosRequestConfig } from 'axios'; import { inject, injectable } from 'inversify'; -import { IPriceProvider } from './IPriceProvider'; +import { IPriceProvider, TokenInfo } from './IPriceProvider'; import { ContainerTypes } from '../containertypes'; import { IFirebaseService } from './FirebaseService'; @@ -30,6 +30,15 @@ export class CoinGeckoPriceProvider implements IPriceProvider { return 0; } + public async getPriceWithTimestamp(symbol: string, currency: string | undefined): Promise{ + const price = await this.getPrice(symbol, currency); + + return { + price, + lastUpdated: Date.now(), + }; + } + private async getTokenId(symbol: string): Promise { if (!CoinGeckoPriceProvider.tokens) { // Cache received data since token list is a quite big. diff --git a/src/services/DiaDataPriceProvider.ts b/src/services/DiaDataPriceProvider.ts index 84ebd46..a2c9256 100644 --- a/src/services/DiaDataPriceProvider.ts +++ b/src/services/DiaDataPriceProvider.ts @@ -19,4 +19,13 @@ export class DiaDataPriceProvider implements IPriceProvider { return 0; } + + public async getPriceWithTimestamp(symbol: string): Promise<{ price: number; lastUpdated: number }> { + const price = await this.getPrice(symbol); + + return { + price, + lastUpdated: Date.now(), + }; + } } diff --git a/src/services/IPriceProvider.ts b/src/services/IPriceProvider.ts index a44b713..90f8b17 100644 --- a/src/services/IPriceProvider.ts +++ b/src/services/IPriceProvider.ts @@ -1,3 +1,9 @@ +export type TokenInfo = { + price: number; + lastUpdated: number; +} + + /** * Definition of provider for access token price. */ @@ -7,4 +13,10 @@ export interface IPriceProvider { * @param tokenInfo Token information. */ getPrice(symbol: string, currency: string | undefined): Promise; + + /** + * Gets current token price in USD with timestamp. + * @param tokenInfo Token price and timestamp. + */ + getPriceWithTimestamp(symbol: string, currency: string | undefined): Promise; } diff --git a/src/services/PriceProviderWithFailover.ts b/src/services/PriceProviderWithFailover.ts index a2ed109..b5b6b50 100644 --- a/src/services/PriceProviderWithFailover.ts +++ b/src/services/PriceProviderWithFailover.ts @@ -1,7 +1,7 @@ import { injectable } from 'inversify'; import container from '../container'; import { ContainerTypes } from '../containertypes'; -import { IPriceProvider } from './IPriceProvider'; +import { IPriceProvider, TokenInfo } from './IPriceProvider'; import { CacheService } from './CacheService'; import { Guard } from '../guard'; @@ -20,26 +20,41 @@ export class PriceProviderWithFailover implements IPriceProvider { */ public async getPrice(symbol: string, currency = 'usd'): Promise { Guard.ThrowIfUndefined('symbol', symbol); + const priceInfo = await this.getPriceWithTimestamp(symbol, currency); + + return priceInfo.price; + } + + public async getPriceWithTimestamp(symbol: string, currency = 'usd'): Promise { + Guard.ThrowIfUndefined('symbol', symbol); const providers = container.getAll(ContainerTypes.PriceProvider); const cacheKey = `${symbol}-${currency}`; + const cacheItem = this.priceCache.getItem(cacheKey); + + if (cacheItem && !this.priceCache.isExpired(cacheItem)) { + // Price is cached and still valid. + return { price: cacheItem.data, lastUpdated: cacheItem.updatedAt }; + } + + // Fetch a new price. for (const provider of providers) { try { - const cacheItem = this.priceCache.getItem(cacheKey); - if (cacheItem) { - return cacheItem; - } else { - const price = await provider.getPrice(symbol, currency); - this.priceCache.setItem(cacheKey, price); - - return price; - } + const price = await provider.getPrice(symbol, currency); + this.priceCache.setItem(cacheKey, price); + + return { price, lastUpdated: Date.now() }; } catch (error) { // Execution moves to next price provider, so nothing special to do here. console.log(error); } } - return 0; + // Last resort, all providers failed. Return from cache. + if (cacheItem) { + return { price: cacheItem.data, lastUpdated: cacheItem.updatedAt }; + } else { + throw new Error('Unable to fetch price from any provider.'); + } } }