Skip to content

Commit

Permalink
Add IPortfolio datasource with Octav implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Jan 15, 2025
1 parent fdafaa6 commit 5484941
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
# PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL=
# PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY=

# Portfolio Provider - Octav
# The portfolio provider to be used.
# (default=https://octav-api.hasura.app)
# PORTFOLIO_API_BASE_URI=
# The API key to be used.
# PORTFOLIO_API_KEY=

# Relay Provider
# The relay provider to be used.
# (default='https://api.gelato.digital')
Expand Down
4 changes: 4 additions & 0 deletions src/config/configuration.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('Configuration validator', () => {
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: faker.internet.email(),
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY:
faker.string.alphanumeric(),
PORTFOLIO_API_KEY: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(),
Expand Down Expand Up @@ -76,6 +77,7 @@ describe('Configuration validator', () => {
{ key: 'PUSH_NOTIFICATIONS_API_PROJECT' },
{ key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL' },
{ key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY' },
{ key: 'PORTFOLIO_API_KEY' },
{ key: 'RELAY_PROVIDER_API_KEY_OPTIMISM' },
{ key: 'RELAY_PROVIDER_API_KEY_BSC' },
{ key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' },
Expand Down Expand Up @@ -127,6 +129,7 @@ describe('Configuration validator', () => {
faker.internet.email(),
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY:
faker.string.alphanumeric(),
PORTFOLIO_API_KEY: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(),
Expand Down Expand Up @@ -177,6 +180,7 @@ describe('Configuration validator', () => {
faker.internet.email(),
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY:
faker.string.alphanumeric(),
PORTFOLIO_API_KEY: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_OPTIMISM: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_BSC: faker.string.uuid(),
RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(),
Expand Down
4 changes: 4 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ export default (): ReturnType<typeof configuration> => ({
owners: {
ownersTtlSeconds: faker.number.int(),
},
portfolio: {
baseUri: faker.internet.url({ appendSlash: false }),
apiKey: faker.string.hexadecimal({ length: 32 }),
},
pushNotifications: {
baseUri: faker.internet.url({ appendSlash: false }),
project: faker.word.noun(),
Expand Down
5 changes: 5 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ export default () => ({
maxOverviews: parseInt(process.env.MAX_SAFE_OVERVIEWS ?? `${10}`),
},
},
portfolio: {
baseUri:
process.env.PORTFOLIO_API_BASE_URI || 'https://octav-api.hasura.app',
apiKey: process.env.PORTFOLIO_API_KEY,
},
pushNotifications: {
baseUri:
process.env.PUSH_NOTIFICATIONS_API_BASE_URI ||
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/schemas/configuration.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const RootConfigurationSchema = z
INFURA_API_KEY: z.string(),
JWT_ISSUER: z.string(),
JWT_SECRET: z.string(),
PORTFOLIO_API_KEY: z.string(),
PUSH_NOTIFICATIONS_API_PROJECT: z.string(),
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email(),
PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(),
Expand Down
114 changes: 114 additions & 0 deletions src/datasources/portfolio-api/octav-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service';
import { DataSourceError } from '@/domain/errors/data-source.error';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
import { OctavApi } from '@/datasources/portfolio-api/octav-api.service';
import { rawify } from '@/validation/entities/raw.entity';
import type { INetworkService } from '@/datasources/network/network.service.interface';
import type { Portfolio } from '@/domain/portfolio/entities/portfolio.entity';

const mockNetworkService = jest.mocked({
get: jest.fn(),
} as jest.MockedObjectDeep<INetworkService>);

describe('OctavApiService', () => {
let target: OctavApi;
let fakeConfigurationService: FakeConfigurationService;
let httpErrorFactory: HttpErrorFactory;
let baseUri: string;
let apiKey: string;

beforeEach(() => {
jest.resetAllMocks();

fakeConfigurationService = new FakeConfigurationService();
httpErrorFactory = new HttpErrorFactory();
baseUri = faker.internet.url({ appendSlash: false });
apiKey = faker.string.sample();
fakeConfigurationService.set('portfolio.baseUri', baseUri);
fakeConfigurationService.set('portfolio.apiKey', apiKey);

target = new OctavApi(
fakeConfigurationService,
mockNetworkService,
httpErrorFactory,
);
});

it('should error if baseUri is not defined', () => {
const httpErrorFactory = new HttpErrorFactory();
const _fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set('portfolio.apiKey', apiKey);

expect(
() =>
new OctavApi(
_fakeConfigurationService,
mockNetworkService,
httpErrorFactory,
),
).toThrow();
});

it('should error if apiKey is not defined', () => {
const httpErrorFactory = new HttpErrorFactory();
const _fakeConfigurationService = new FakeConfigurationService();
fakeConfigurationService.set('portfolio.baseUri', baseUri);

expect(
() =>
new OctavApi(
_fakeConfigurationService,
mockNetworkService,
httpErrorFactory,
),
).toThrow();
});

describe('getPortfolio', () => {
it('should get portfolio', async () => {
const safeAddress = getAddress(faker.finance.ethereumAddress());
const portfolio: Portfolio = {};
mockNetworkService.get.mockResolvedValueOnce({
status: 200,
data: rawify(portfolio),
});

await target.getPortfolio(safeAddress);

expect(mockNetworkService.get).toHaveBeenCalledWith({
url: `${baseUri}/api/rest/portfolio`,
networkRequest: {
headers: {
Authorization: `Bearer ${apiKey}`,
},
params: {
addresses: safeAddress,
includeImages: true,
},
},
});
});

it('should forward error', async () => {
const safeAddress = getAddress(faker.finance.ethereumAddress());
const status = faker.internet.httpStatusCode({ types: ['serverError'] });
const error = new NetworkResponseError(
new URL(`${baseUri}/api/rest/portfolio`),
{
status,
} as Response,
{
message: 'Unexpected error',
},
);
mockNetworkService.get.mockRejectedValueOnce(error);

await expect(target.getPortfolio(safeAddress)).rejects.toThrow(
new DataSourceError('Unexpected error', status),
);
});
});
});
50 changes: 50 additions & 0 deletions src/datasources/portfolio-api/octav-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Inject, Injectable } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import {
NetworkService,
INetworkService,
} from '@/datasources/network/network.service.interface';
import { IPortfolioApi } from '@/domain/interfaces/portfolio-api.interface';
import { Portfolio } from '@/domain/portfolio/entities/portfolio.entity';
import { Raw } from '@/validation/entities/raw.entity';

@Injectable()
export class OctavApi implements IPortfolioApi {
private readonly baseUri: string;
private readonly apiKey: string;

constructor(
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(NetworkService)
private readonly networkService: INetworkService,
private readonly httpErrorFactory: HttpErrorFactory,
) {
this.baseUri =
this.configurationService.getOrThrow<string>('portfolio.baseUri');
this.apiKey =
this.configurationService.getOrThrow<string>('portfolio.apiKey');
}

async getPortfolio(safeAddress: `0x${string}`): Promise<Raw<Portfolio>> {
try {
const url = `${this.baseUri}/api/rest/portfolio`;
const { data: portfolio } = await this.networkService.get<Portfolio>({
url,
networkRequest: {
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
params: {
addresses: safeAddress,
includeImages: true,
},
},
});
return portfolio;
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}
}
10 changes: 10 additions & 0 deletions src/datasources/portfolio-api/portfolio-api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { OctavApi } from '@/datasources/portfolio-api/octav-api.service';
import { IPortfolioApi } from '@/domain/interfaces/portfolio-api.interface';

@Module({
providers: [HttpErrorFactory, { provide: IPortfolioApi, useClass: OctavApi }],
exports: [IPortfolioApi],
})
export class PortfolioApiModule {}
8 changes: 8 additions & 0 deletions src/domain/interfaces/portfolio-api.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Portfolio } from '@/domain/portfolio/entities/portfolio.entity';
import type { Raw } from '@/validation/entities/raw.entity';

export const IPortfolioApi = Symbol('IPortfolioApi');

export interface IPortfolioApi {
getPortfolio(safeAddress: `0x${string}`): Promise<Raw<Portfolio>>;
}
1 change: 1 addition & 0 deletions src/domain/portfolio/entities/portfolio.entity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
it.todo('PortfolioSchema');
5 changes: 5 additions & 0 deletions src/domain/portfolio/entities/portfolio.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from 'zod';

export const PortfolioSchema = z.unknown();

export type Portfolio = z.infer<typeof PortfolioSchema>;
3 changes: 3 additions & 0 deletions test/e2e-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL =
'email@fake-email.com';
process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY =
'fake-private-key';

// For E2E tests, portfolio API dummy value
process.env.PORTFOLIO_API_KEY = 'fake-api-key';

0 comments on commit 5484941

Please sign in to comment.