Skip to content

Commit

Permalink
Implement simple CoinGecko client
Browse files Browse the repository at this point in the history
Refactor the ApplicationException to capture an original error and prevent
information loss.
  • Loading branch information
wcalderipe committed Jan 29, 2024
1 parent 9ac2b60 commit ad672a4
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 0 deletions.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SimplePrice } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.type'

export const generateSimplePrice = (): SimplePrice => {
return {
ethereum: {
usd: 2272.194867253841,
usd_market_cap: 272895913402.66522,
usd_24h_vol: 6827306177.886258,
usd_24h_change: -0.8104607737461819,
eur: 2101.0645108266203,
eur_market_cap: 252292271940.76367,
eur_24h_vol: 6313107613.0987625,
eur_24h_change: -0.36025898833091113,
last_updated_at: 1706524566
},
uniswap: {
usd: 5.973969024087618,
usd_market_cap: 4496856084.101798,
usd_24h_vol: 74037282.24412505,
usd_24h_change: -0.8629967474909083,
eur: 5.522934362768995,
eur_market_cap: 4157343449.752105,
eur_24h_vol: 68447467.43469352,
eur_24h_change: -0.4329575908228408,
last_updated_at: 1706524541
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { generateSimplePrice } from '@app/orchestration/price/http/client/coin-gecko/__test__/fixture/coin-gecko.fixture'
import { CoinGeckoClient } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.client'
import { CoinGeckoException } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.exception'
import { HttpModule } from '@nestjs/axios'
import { HttpStatus } from '@nestjs/common'
import { Test, TestingModule } from '@nestjs/testing'
import { lowerCase } from 'lodash/fp'
import nock from 'nock'

describe(CoinGeckoClient.name, () => {
let module: TestingModule
let client: CoinGeckoClient

beforeEach(async () => {
module = await Test.createTestingModule({
imports: [HttpModule],
providers: [CoinGeckoClient]
}).compile()

client = module.get<CoinGeckoClient>(CoinGeckoClient)
})

describe('getSimplePrice', () => {
it('returns unchangeable simple price', async () => {
const ids = ['ETHEREUM', 'uniswap']
const currencies = ['USD', 'eur']
const options = {
include_market_cap: true,
include_24h_volume: true,
include_24h_change: true,
include_last_updated_at: true,
precision: 18
}
const response = generateSimplePrice()

nock(CoinGeckoClient.V3_URL)
.get('/simple/price')
.query({
ids: ids.map(lowerCase).join(','),
vs_currencies: currencies.map(lowerCase).join(','),
...options
})
.reply(HttpStatus.OK, response)

const simplePrice = await client.getSimplePrice({
url: CoinGeckoClient.V3_URL,
data: {
ids,
vs_currencies: currencies,
...options
}
})

expect(simplePrice.ethereum).toEqual(response.ethereum)
})

it('throws CoinGeckoException on errors', async () => {
nock(CoinGeckoClient.V3_URL)
.get('/simple/price')
.query({
ids: 'ethereum',
vs_currencies: 'usd'
})
.reply(HttpStatus.INTERNAL_SERVER_ERROR, {
boom: 'something went wrong'
})

expect(() => {
return client.getSimplePrice({
url: CoinGeckoClient.V3_URL,
data: {
ids: ['ethereum'],
vs_currencies: ['usd']
}
})
}).rejects.toThrow(CoinGeckoException)
})

it('omits api key from exception data', async () => {
const apiKey = 'test-api-key'
const nockOption = {
reqheaders: {
[CoinGeckoClient.AUTH_HEADER]: apiKey
}
}

nock(CoinGeckoClient.V3_URL, nockOption)
.get('/simple/price')
.query({
ids: 'ethereum',
vs_currencies: 'usd'
})
.reply(HttpStatus.INTERNAL_SERVER_ERROR, {
boom: 'something went wrong'
})

expect.assertions(2)

try {
await client.getSimplePrice({
url: CoinGeckoClient.V3_URL,
apiKey,
data: {
ids: ['ethereum'],
vs_currencies: ['usd']
}
})
} catch (error) {
expect(error.context.request.headers[CoinGeckoClient.AUTH_HEADER]).toEqual(undefined)
}
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { CoinGeckoException } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.exception'
import { SimplePrice, SimplePriceOption } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.type'
import { HttpService } from '@nestjs/axios'
import { HttpStatus, Injectable } from '@nestjs/common'
import { AxiosError, AxiosRequestConfig } from 'axios'
import { lowerCase } from 'lodash'
import { omit } from 'lodash/fp'
import { catchError, lastValueFrom, map, throwError } from 'rxjs'

@Injectable()
export class CoinGeckoClient {
constructor(private httpService: HttpService) {}

static AUTH_HEADER = 'x-cg-pro-api-key'
static V3_URL = 'https://api.coingecko.com/api/v3'

getSimplePrice(options: SimplePriceOption): Promise<SimplePrice> {
const request = {
method: 'get',
url: `${options.url}/simple/price`,
headers: {
...(options.apiKey && { [CoinGeckoClient.AUTH_HEADER]: options.apiKey })
},
params: {
ids: this.formatStringArray(options.data.ids),
vs_currencies: this.formatStringArray(options.data.vs_currencies),
precision: options.data.precision,
include_market_cap: options.data.include_market_cap,
include_24h_volume: options.data.include_24h_volume,
include_24h_change: options.data.include_24h_change,
include_last_updated_at: options.data.include_last_updated_at
}
}

return lastValueFrom(
this.httpService.request<SimplePrice>(request).pipe(
map((response) => response.data),
catchError((error) => this.throwError(request, error))
)
)
}

private formatStringArray(value: string[]): string {
return value.map(lowerCase).join(',')
}

private throwError(request: AxiosRequestConfig, error: Error) {
const redactedRequest = {
...request,
headers: omit(CoinGeckoClient.AUTH_HEADER, request.headers)
}

if (error instanceof AxiosError) {
return throwError(
() =>
new CoinGeckoException({
message: 'Request to CoinGecko failed',
suggestedHttpStatusCode: error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
originalError: error,
context: {
cause: error.cause,
request: redactedRequest,
response: {
data: error.response?.data,
status: error.response?.status,
headers: error.response?.headers
}
}
})
)
}

return throwError(
() =>
new CoinGeckoException({
message: 'Unknown CoinGecko client error',
suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR,
originalError: error,
context: {
request: redactedRequest
}
})
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ApplicationException } from '@app/orchestration/shared/exception/application.exception'

export class CoinGeckoException extends ApplicationException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
type CoinId = string

type Currency = 'usd' | 'eur'

type Option<Data> = {
url: string
apiKey?: string
data: Data
}

type Price = {
[C in Currency]: number
}

type ChangeMetadata = {
[C in Currency as `${C}_24h_change`]?: number
}

type MarketCapMetadata = {
[C in Currency as `${C}_market_cap`]?: number
}

type VolumeMetadata = {
[C in Currency as `${C}_24h_vol`]?: number
}

export type SimplePriceOption = Option<{
ids: string[]
vs_currencies: string[]
include_market_cap?: boolean
include_24h_volume?: boolean
include_24h_change?: boolean
include_last_updated_at?: boolean
precision?: number
}>

type SimplePriceMetadata = ChangeMetadata &
MarketCapMetadata &
VolumeMetadata & {
last_updated_at?: number
}

export type SimplePrice = Record<CoinId, Price & SimplePriceMetadata>
10 changes: 10 additions & 0 deletions apps/orchestration/src/price/price.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { CoinGeckoClient } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.client'
import { HttpModule } from '@nestjs/axios'
import { Module } from '@nestjs/common'

@Module({
imports: [HttpModule],
providers: [CoinGeckoClient],
exports: [CoinGeckoClient]
})
export class PriceModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ export type ApplicationExceptionParams = {
message: string
suggestedHttpStatusCode: HttpStatus
context?: unknown
originalError?: Error
}

export class ApplicationException extends HttpException {
readonly context: unknown
readonly originalError?: Error

constructor(params: ApplicationExceptionParams) {
super(params.message, params.suggestedHttpStatusCode)

if (Error.captureStackTrace) {
Error.captureStackTrace(this, ApplicationException)
}

this.name = this.constructor.name
this.context = params.context
this.originalError = params.originalError
}
}

0 comments on commit ad672a4

Please sign in to comment.