Skip to content

Commit

Permalink
Use CacheFirstDataSource in ExchangeApi (safe-global#316)
Browse files Browse the repository at this point in the history
- `ExchangeApi` now uses `CacheFirstDataSource` in order to avoid calling external fiat rate exchange endpoints more often than required
  • Loading branch information
fmrsabino authored May 9, 2023
1 parent 231a028 commit 70fa340
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 22 deletions.
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default () => ({
baseUri:
process.env.EXCHANGE_API_BASE_URI || 'http://api.exchangeratesapi.io/v1',
apiKey: process.env.EXCHANGE_API_KEY,
cacheTtlSeconds: process.env.EXCHANGE_API_CACHE_TTL_SECONDS,
},
safeConfig: {
baseUri:
Expand Down
10 changes: 10 additions & 0 deletions src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export class CacheRouter {
private static readonly CONTRACT_KEY = 'contract';
private static readonly CREATION_TRANSACTION_KEY = 'creation_transaction';
private static readonly DELEGATES_KEY = 'delegates';
private static readonly EXCHANGE_FIAT_CODES_KEY = 'exchange_fiat_codes';
private static readonly EXCHANGE_RATES_KEY = 'exchange_rates';
private static readonly INCOMING_TRANSFERS_KEY = 'incoming_transfers';
private static readonly MASTER_COPIES_KEY = 'master_copies';
private static readonly MESSAGE_KEY = 'message';
Expand Down Expand Up @@ -279,4 +281,12 @@ export class CacheRouter {
`${clientUrl}_${url}`,
);
}

static getExchangeFiatCodesCacheDir(): CacheDir {
return new CacheDir(CacheRouter.EXCHANGE_FIAT_CODES_KEY, '');
}

static getExchangeRatesCacheDir(): CacheDir {
return new CacheDir(CacheRouter.EXCHANGE_RATES_KEY, '');
}
}
2 changes: 2 additions & 0 deletions src/datasources/exchange-api/exchange-api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { Global, Module } from '@nestjs/common';
import { HttpErrorFactory } from '../errors/http-error-factory';
import { ExchangeApi } from './exchange-api.service';
import { IExchangeApi } from '../../domain/interfaces/exchange-api.interface';
import { CacheFirstDataSourceModule } from '../cache/cache.first.data.source.module';

@Global()
@Module({
imports: [CacheFirstDataSourceModule],
providers: [
HttpErrorFactory,
{ provide: IExchangeApi, useClass: ExchangeApi },
Expand Down
102 changes: 92 additions & 10 deletions src/datasources/exchange-api/exchange-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
import { ExchangeApi } from './exchange-api.service';
import { FakeConfigurationService } from '../../config/__tests__/fake.configuration.service';
import { mockNetworkService } from '../network/__tests__/test.network.module';
import { faker } from '@faker-js/faker';
import { exchangeFiatCodesBuilder } from '../../domain/exchange/entities/__tests__/exchange-fiat-codes.builder';
import { exchangeRatesBuilder } from '../../domain/exchange/entities/__tests__/exchange-rates.builder';
import { CacheFirstDataSource } from '../cache/cache.first.data.source';
import { CacheDir } from '../cache/entities/cache-dir.entity';

const mockCacheFirstDataSource = jest.mocked({
get: jest.fn(),
} as unknown as CacheFirstDataSource);

describe('ExchangeApi', () => {
let service: ExchangeApi;
let fakeConfigurationService: FakeConfigurationService;
const exchangeBaseUri = faker.internet.url();
const exchangeApiKey = faker.random.alphaNumeric();

beforeAll(async () => {
fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set('exchange.baseUri', faker.internet.url());
fakeConfigurationService.set(
'exchange.apiKey',
faker.random.alphaNumeric(),
);
fakeConfigurationService.set('exchange.baseUri', exchangeBaseUri);
fakeConfigurationService.set('exchange.apiKey', exchangeApiKey);
});

beforeEach(async () => {
jest.clearAllMocks();
service = new ExchangeApi(fakeConfigurationService, mockNetworkService);
service = new ExchangeApi(
fakeConfigurationService,
mockCacheFirstDataSource,
);
});

it('should error if configuration is not defined', async () => {
const fakeConfigurationService = new FakeConfigurationService();
await expect(
() => new ExchangeApi(fakeConfigurationService, mockNetworkService),
() => new ExchangeApi(fakeConfigurationService, mockCacheFirstDataSource),
).toThrow();
});

Expand All @@ -37,23 +44,98 @@ describe('ExchangeApi', () => {
USD: 'Dollar',
})
.build();
mockNetworkService.get.mockResolvedValue({ data: expectedFiatCodes });
mockCacheFirstDataSource.get.mockResolvedValue(expectedFiatCodes);

const fiatCodes = await service.getFiatCodes();

expect(fiatCodes).toBe(expectedFiatCodes);
});

it('fiatCodes uses default cache TTL (12h) if none is set', async () => {
const expectedFiatCodes = exchangeFiatCodesBuilder().build();
mockCacheFirstDataSource.get.mockResolvedValue(expectedFiatCodes);

await service.getFiatCodes();

expect(mockCacheFirstDataSource.get).toBeCalledWith(
new CacheDir('exchange_fiat_codes', ''),
`${exchangeBaseUri}/symbols`,
{ params: { access_key: exchangeApiKey } },
60 * 60 * 12, // 12h in seconds
);
});

it('fiatCodes uses set cache TTL', async () => {
const exchangeBaseUri = faker.internet.url();
const exchangeApiKey = faker.random.alphaNumeric();
const ttl = 60;
const fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set('exchange.baseUri', exchangeBaseUri);
fakeConfigurationService.set('exchange.apiKey', exchangeApiKey);
fakeConfigurationService.set('exchange.cacheTtlSeconds', ttl);
const expectedFiatCodes = exchangeFiatCodesBuilder().build();
mockCacheFirstDataSource.get.mockResolvedValue(expectedFiatCodes);
const target = new ExchangeApi(
fakeConfigurationService,
mockCacheFirstDataSource,
);

await target.getFiatCodes();

expect(mockCacheFirstDataSource.get).toBeCalledWith(
new CacheDir('exchange_fiat_codes', ''),
`${exchangeBaseUri}/symbols`,
{ params: { access_key: exchangeApiKey } },
ttl, // 60 seconds
);
});

it('Should return the exchange rates', async () => {
const expectedRates = exchangeRatesBuilder()
.with('success', true)
.with('rates', {
USD: faker.datatype.number(),
});
mockNetworkService.get.mockResolvedValue({ data: expectedRates });
mockCacheFirstDataSource.get.mockResolvedValue(expectedRates);

const rates = await service.getRates();

expect(rates).toBe(expectedRates);
});

it('exchangeRates uses default cache TTL (12h) if none is set', async () => {
await service.getRates();

expect(mockCacheFirstDataSource.get).toBeCalledWith(
new CacheDir('exchange_rates', ''),
`${exchangeBaseUri}/latest`,
{ params: { access_key: exchangeApiKey } },
60 * 60 * 12, // 12h in seconds
);
});

it('exchangeRates uses set cache TTL', async () => {
const exchangeBaseUri = faker.internet.url();
const exchangeApiKey = faker.random.alphaNumeric();
const ttl = 60;
const fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set('exchange.baseUri', exchangeBaseUri);
fakeConfigurationService.set('exchange.apiKey', exchangeApiKey);
fakeConfigurationService.set('exchange.cacheTtlSeconds', ttl);
const expectedFiatCodes = exchangeFiatCodesBuilder().build();
mockCacheFirstDataSource.get.mockResolvedValue(expectedFiatCodes);
const target = new ExchangeApi(
fakeConfigurationService,
mockCacheFirstDataSource,
);

await target.getRates();

expect(mockCacheFirstDataSource.get).toBeCalledWith(
new CacheDir('exchange_rates', ''),
`${exchangeBaseUri}/latest`,
{ params: { access_key: exchangeApiKey } },
ttl, // 60 seconds
);
});
});
33 changes: 21 additions & 12 deletions src/datasources/exchange-api/exchange-api.service.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExchangeRates } from '../../domain/exchange/entities/exchange-rates.entity';
import {
INetworkService,
NetworkService,
} from '../network/network.service.interface';
import { ExchangeFiatCodes } from '../../domain/exchange/entities/exchange-fiat-codes.entity';
import { IExchangeApi } from '../../domain/interfaces/exchange-api.interface';
import { DataSourceError } from '../../domain/errors/data-source.error';
import { IConfigurationService } from '../../config/configuration.service.interface';
import { CacheFirstDataSource } from '../cache/cache.first.data.source';
import { CacheRouter } from '../cache/cache.router';

@Injectable()
export class ExchangeApi implements IExchangeApi {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly cacheTtlSeconds: number;

private static readonly DEFAULT_CACHE_TTL_SECONDS = 60 * 60 * 12;

constructor(
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(NetworkService) private readonly networkService: INetworkService,
private readonly dataSource: CacheFirstDataSource,
) {
this.baseUrl =
this.configurationService.getOrThrow<string>('exchange.baseUri');
this.apiKey =
this.configurationService.getOrThrow<string>('exchange.apiKey');
this.cacheTtlSeconds =
this.configurationService.get<number | undefined>(
'exchange.cacheTtlSeconds',
) ?? ExchangeApi.DEFAULT_CACHE_TTL_SECONDS;
}

async getFiatCodes(): Promise<ExchangeFiatCodes> {
try {
const { data } = await this.networkService.get(
return await this.dataSource.get<ExchangeFiatCodes>(
CacheRouter.getExchangeFiatCodesCacheDir(),
`${this.baseUrl}/symbols`,
{
params: { access_key: this.apiKey },
},
this.cacheTtlSeconds,
);
return data;
} catch (error) {
throw new DataSourceError('Error getting Fiat Codes from exchange');
}
}

async getRates(): Promise<ExchangeRates> {
try {
const { data } = await this.networkService.get(`${this.baseUrl}/latest`, {
params: { access_key: this.apiKey },
});

return data;
return await this.dataSource.get<ExchangeRates>(
CacheRouter.getExchangeRatesCacheDir(),
`${this.baseUrl}/latest`,
{
params: { access_key: this.apiKey },
},
this.cacheTtlSeconds,
);
} catch (error) {
throw new DataSourceError('Error getting exchange data');
}
Expand Down

0 comments on commit 70fa340

Please sign in to comment.