-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor the ApplicationException to capture an original error and prevent information loss.
- Loading branch information
1 parent
9ac2b60
commit ad672a4
Showing
8 changed files
with
289 additions
and
0 deletions.
There are no files selected for viewing
Empty file.
28 changes: 28 additions & 0 deletions
28
apps/orchestration/src/price/http/client/coin-gecko/__test__/fixture/coin-gecko.fixture.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
113 changes: 113 additions & 0 deletions
113
...estration/src/price/http/client/coin-gecko/__test__/integration/coin-gecko.client.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
}) | ||
}) |
85 changes: 85 additions & 0 deletions
85
apps/orchestration/src/price/http/client/coin-gecko/coin-gecko.client.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
}) | ||
) | ||
} | ||
} |
3 changes: 3 additions & 0 deletions
3
apps/orchestration/src/price/http/client/coin-gecko/coin-gecko.exception.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} |
43 changes: 43 additions & 0 deletions
43
apps/orchestration/src/price/http/client/coin-gecko/coin-gecko.type.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters