From e965d12e310a0ac4f8239b06aed27689065a074c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 22 Apr 2023 16:03:45 +0200 Subject: [PATCH] Feature/add health check endpoints (#1886) * Add health check endpoints * Update changelog --- CHANGELOG.md | 2 + apps/api/src/app/app.module.ts | 2 + apps/api/src/app/health/health.controller.ts | 44 +++++++++++++++++++ apps/api/src/app/health/health.module.ts | 13 ++++++ apps/api/src/app/health/health.service.ts | 14 ++++++ .../alpha-vantage/alpha-vantage.service.ts | 6 ++- .../coingecko/coingecko.service.ts | 4 ++ .../data-provider/data-provider.service.ts | 18 ++++++++ .../eod-historical-data.service.ts | 4 ++ .../google-sheets/google-sheets.service.ts | 4 ++ .../interfaces/data-provider.interface.ts | 2 + .../data-provider/manual/manual.service.ts | 4 ++ .../rapid-api/rapid-api.service.ts | 4 ++ .../yahoo-finance/yahoo-finance.service.ts | 5 +++ 14 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/health/health.controller.ts create mode 100644 apps/api/src/app/health/health.module.ts create mode 100644 apps/api/src/app/health/health.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f2e9dc34..11b9b2b11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a fallback to historical market data if a data provider does not provide live data +- Added a general health check endpoint +- Added health check endpoints for data providers ### Changed diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index d7faee2d2..0db6d8949 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -24,6 +24,7 @@ import { CacheModule } from './cache/cache.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; import { FrontendMiddleware } from './frontend.middleware'; +import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; import { LogoModule } from './logo/logo.module'; @@ -57,6 +58,7 @@ import { UserModule } from './user/user.module'; ExchangeRateModule, ExchangeRateDataModule, ExportModule, + HealthModule, ImportModule, InfoModule, LogoModule, diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts new file mode 100644 index 000000000..699a996dd --- /dev/null +++ b/apps/api/src/app/health/health.controller.ts @@ -0,0 +1,44 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { + Controller, + Get, + HttpException, + Param, + UseInterceptors +} from '@nestjs/common'; + +import { HealthService } from './health.service'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Controller('health') +export class HealthController { + public constructor(private readonly healthService: HealthService) {} + + @Get() + public async getHealth() {} + + @Get('data-provider/:dataSource') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getHealthOfDataProvider( + @Param('dataSource') dataSource: DataSource + ) { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const hasResponse = await this.healthService.hasResponseFromDataProvider( + dataSource + ); + + if (hasResponse !== true) { + throw new HttpException( + getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + StatusCodes.SERVICE_UNAVAILABLE + ); + } + } +} diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts new file mode 100644 index 000000000..2aab03bfa --- /dev/null +++ b/apps/api/src/app/health/health.module.ts @@ -0,0 +1,13 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { Module } from '@nestjs/common'; + +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + imports: [ConfigurationModule, DataProviderModule], + providers: [HealthService] +}) +export class HealthModule {} diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts new file mode 100644 index 000000000..afbcc0a74 --- /dev/null +++ b/apps/api/src/app/health/health.service.ts @@ -0,0 +1,14 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; + +@Injectable() +export class HealthService { + public constructor( + private readonly dataProviderService: DataProviderService + ) {} + + public async hasResponseFromDataProvider(aDataSource: DataSource) { + return this.dataProviderService.checkQuote(aDataSource); + } +} diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts index 88487bfb2..fed0a71ce 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -7,7 +7,7 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { format, isAfter, isBefore, parse } from 'date-fns'; @@ -110,6 +110,10 @@ export class AlphaVantageService implements DataProviderInterface { return {}; } + public getTestSymbol() { + return undefined; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { const result = await this.alphaVantage.data.search(aQuery); diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index 70fa6f039..fd461a3d9 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -160,6 +160,10 @@ export class CoinGeckoService implements DataProviderInterface { return results; } + public getTestSymbol() { + return 'bitcoin'; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { let items: LookupItem[] = []; diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 0fd7cceba..2076fafd6 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -38,6 +38,24 @@ export class DataProviderService { }) ?? {}; } + public async checkQuote(dataSource: DataSource) { + const dataProvider = this.getDataProvider(dataSource); + const symbol = dataProvider.getTestSymbol(); + + const quotes = await this.getQuotes([ + { + dataSource, + symbol + } + ]); + + if (quotes[symbol]?.marketPrice > 0) { + return true; + } + + return false; + } + public async getDividends({ dataSource, from, diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index 910039654..b9038a285 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -172,6 +172,10 @@ export class EodHistoricalDataService implements DataProviderInterface { return {}; } + public getTestSymbol() { + return 'AAPL.US'; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { const searchResult = await this.getSearchResult(aQuery); diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index f315a891c..cc4c6af2b 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -143,6 +143,10 @@ export class GoogleSheetsService implements DataProviderInterface { return {}; } + public getTestSymbol() { + return 'INDEXSP:.INX'; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { const items = await this.prismaService.symbolProfile.findMany({ select: { diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index c51adb985..502e14982 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -40,5 +40,7 @@ export interface DataProviderInterface { aSymbols: string[] ): Promise<{ [symbol: string]: IDataProviderResponse }>; + getTestSymbol(): string; + search(aQuery: string): Promise<{ items: LookupItem[] }>; } diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index da58fefbe..9d92628e9 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -163,6 +163,10 @@ export class ManualService implements DataProviderInterface { return {}; } + public getTestSymbol() { + return undefined; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { let items = await this.prismaService.symbolProfile.findMany({ select: { diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index b0f185efc..f8ad2d8a1 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -113,6 +113,10 @@ export class RapidApiService implements DataProviderInterface { return {}; } + public getTestSymbol() { + return undefined; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { return { items: [] }; } diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 863ee5878..680687b3b 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -167,6 +167,7 @@ export class YahooFinanceService implements DataProviderInterface { if (aSymbols.length <= 0) { return {}; } + const yahooFinanceSymbols = aSymbols.map((symbol) => this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) ); @@ -251,6 +252,10 @@ export class YahooFinanceService implements DataProviderInterface { } } + public getTestSymbol() { + return 'AAPL'; + } + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { const items: LookupItem[] = [];