Skip to content

Commit

Permalink
Add datasource for Safe Decoder Service
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Jan 29, 2025
1 parent b22f1f0 commit 4b4cb62
Show file tree
Hide file tree
Showing 18 changed files with 1,190 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ export default (): ReturnType<typeof configuration> => ({
maxSequentialPages: faker.number.int(),
},
},
safeDataDecoder: {
baseUri: faker.internet.url({ appendSlash: false }),
},
safeTransaction: {
useVpcUrl: false,
},
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 @@ -364,6 +364,11 @@ export default () => ({
),
},
},
safeDataDecoder: {
baseUri:
process.env.SAFE_DATA_DECODER_BASE_URI ||
'https://safe-decoder.safe.global',
},
safeTransaction: {
useVpcUrl: process.env.USE_TX_SERVICE_VPC_URL?.toLowerCase() === 'true',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManager } from '@/datasources/data-decoder-api/data-decoder-api.manager';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Module({
providers: [
{
provide: IDataDecoderApiManager,
useClass: DataDecoderApiManager,
},
HttpErrorFactory,
],
exports: [IDataDecoderApiManager],
})
export class DataDecoderApiManagerModule {}
51 changes: 51 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import { DataDecoderApi } from '@/datasources/data-decoder-api/data-decoder-api.service';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import {
NetworkService,
INetworkService,
} from '@/datasources/network/network.service.interface';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Injectable()
export class DataDecoderApiManager implements IDataDecoderApiManager {
private decoderApiMap: Record<string, DataDecoderApi> = {};

private readonly baseUri: string;

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

async getApi(chainId: string): Promise<DataDecoderApi> {
const decoderApi = this.decoderApiMap[chainId];

if (decoderApi !== undefined) {
return Promise.resolve(decoderApi);
}

this.decoderApiMap[chainId] = new DataDecoderApi(
chainId,
this.baseUri,
this.networkService,
this.httpErrorFactory,
);

return Promise.resolve(this.decoderApiMap[chainId]);
}

// We don't need to destroy the API as it is not event-specific
// eslint-disable-next-line @typescript-eslint/no-unused-vars
destroyApi(_: string): void {
throw new Error('Method not implemented');
}
}
13 changes: 13 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManager } from '@/datasources/data-decoder-api/data-decoder-api.manager';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { IDataDecoderApiManager } from '@/domain/interfaces/data-decoder-api.manager.interface';

@Module({
providers: [
HttpErrorFactory,
{ provide: IDataDecoderApiManager, useClass: DataDecoderApiManager },
],
exports: [IDataDecoderApiManager],
})
export class DataDecodedApiModule {}
163 changes: 163 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import { DataDecoderApi } from '@/datasources/data-decoder-api/data-decoder-api.service';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { dataDecodedBuilder } from '@/domain/data-decoder/v2/entities/__tests__/data-decoded.builder';
import { contractBuilder } from '@/domain/data-decoder/v2/entities/__tests__/contract.builder';
import { pageBuilder } from '@/domain/entities/__tests__/page.builder';
import { DataSourceError } from '@/domain/errors/data-source.error';
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
import { rawify } from '@/validation/entities/raw.entity';
import type { INetworkService } from '@/datasources/network/network.service.interface';

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

describe('DataDecoderApi', () => {
const baseUrl = faker.internet.url({ appendSlash: false });
let chainId: string;
let target: DataDecoderApi;

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

const httpErrorFactory = new HttpErrorFactory();
chainId = faker.string.numeric();
target = new DataDecoderApi(
chainId,
baseUrl,
mockNetworkService,
httpErrorFactory,
);
});

describe('getDataDecoded', () => {
it('should return the decoded data', async () => {
const dataDecoded = dataDecodedBuilder().build();
const to = getAddress(faker.finance.ethereumAddress());
const data = faker.string.hexadecimal() as `0x${string}`;
const getDataDecodedUrl = `${baseUrl}/api/v1/data-decoder`;
mockNetworkService.post.mockImplementation(({ url }) => {
if (url === getDataDecodedUrl) {
return Promise.resolve({ status: 200, data: rawify(dataDecoded) });
}
throw new Error('Unexpected URL');
});

const actual = await target.getDecodedData({ data, to });

expect(actual).toStrictEqual(dataDecoded);
expect(mockNetworkService.post).toHaveBeenCalledTimes(1);
expect(mockNetworkService.post).toHaveBeenCalledWith({
url: getDataDecodedUrl,
data: { chainId: Number(chainId), to, data },
});
});

it('should forward an error', async () => {
const to = getAddress(faker.finance.ethereumAddress());
const data = faker.string.hexadecimal() as `0x${string}`;
const errorMessage = faker.word.words();
const statusCode = faker.internet.httpStatusCode({
types: ['clientError', 'serverError'],
});
const expected = new DataSourceError(errorMessage, statusCode);
const getDataDecodedUrl = `${baseUrl}/api/v1/data-decoder`;
mockNetworkService.post.mockImplementation(({ url }) => {
if (url === getDataDecodedUrl) {
return Promise.reject(
new NetworkResponseError(
new URL(getDataDecodedUrl),
{
status: statusCode,
} as Response,
new Error(errorMessage),
),
);
}
throw new Error('Unexpected URL');
});

await expect(target.getDecodedData({ data, to })).rejects.toThrow(
expected,
);

expect(mockNetworkService.post).toHaveBeenCalledTimes(1);
expect(mockNetworkService.post).toHaveBeenCalledWith({
url: getDataDecodedUrl,
data: { chainId: Number(chainId), to, data },
});
});
});

describe('getContracts', () => {
it('should return the contracts', async () => {
const contract = contractBuilder().build();
const contractPage = pageBuilder().with('results', [contract]).build();
const getContractsUrl = `${baseUrl}/api/v1/contracts/${contract.address}`;
mockNetworkService.get.mockImplementation(({ url }) => {
if (url === getContractsUrl) {
return Promise.resolve({ status: 200, data: rawify(contractPage) });
}
throw new Error('Unexpected URL');
});

const actual = await target.getContracts({ address: contract.address });

expect(actual).toStrictEqual(contractPage);
expect(mockNetworkService.get).toHaveBeenCalledTimes(1);
expect(mockNetworkService.get).toHaveBeenCalledWith({
url: getContractsUrl,
networkRequest: {
params: {
chain_ids: Number(chainId),
limit: undefined,
offset: undefined,
},
},
});
});

it('should forward an error', async () => {
const contract = contractBuilder().build();
const errorMessage = faker.word.words();
const statusCode = faker.internet.httpStatusCode({
types: ['clientError', 'serverError'],
});
const expected = new DataSourceError(errorMessage, statusCode);
const getContractsUrl = `${baseUrl}/api/v1/contracts/${contract.address}`;
mockNetworkService.get.mockImplementation(({ url }) => {
if (url === getContractsUrl) {
return Promise.reject(
new NetworkResponseError(
new URL(getContractsUrl),
{
status: statusCode,
} as Response,
new Error(errorMessage),
),
);
}
throw new Error('Unexpected URL');
});

await expect(
target.getContracts({ address: contract.address }),
).rejects.toThrow(expected);

expect(mockNetworkService.get).toHaveBeenCalledTimes(1);
expect(mockNetworkService.get).toHaveBeenCalledWith({
url: getContractsUrl,
networkRequest: {
params: {
chain_ids: Number(chainId),
limit: undefined,
offset: undefined,
},
},
});
});
});
});
63 changes: 63 additions & 0 deletions src/datasources/data-decoder-api/data-decoder-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import type { INetworkService } from '@/datasources/network/network.service.interface';
import type { DataDecoded } from '@/domain/data-decoder/v2/entities/data-decoded.entity';
import type { Contract } from '@/domain/data-decoder/v2/entities/contract.entity';
import type { Page } from '@/domain/entities/page.entity';
import type { IDataDecoderApi } from '@/domain/interfaces/data-decoder-api.interface';
import type { Raw } from '@/validation/entities/raw.entity';

export class DataDecoderApi implements IDataDecoderApi {
constructor(
private readonly chainId: string,
private readonly baseUrl: string,
private readonly networkService: INetworkService,
private readonly httpErrorFactory: HttpErrorFactory,
) {}

public async getDecodedData(args: {
data: `0x${string}`;
to: `0x${string}`;
}): Promise<Raw<DataDecoded>> {
try {
const url = `${this.baseUrl}/api/v1/data-decoder`;
const { data: dataDecoded } = await this.networkService.post<DataDecoded>(
{
url,
data: {
chainId: Number(this.chainId),
to: args.to,
data: args.data,
},
},
);
return dataDecoded;
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}

public async getContracts(args: {
address: `0x${string}`;
limit?: number;
offset?: number;
}): Promise<Raw<Page<Contract>>> {
try {
const url = `${this.baseUrl}/api/v1/contracts/${args.address}`;
const { data: contracts } = await this.networkService.get<Page<Contract>>(
{
url,
networkRequest: {
params: {
chain_ids: Number(this.chainId),
limit: args.limit,
offset: args.offset,
},
},
},
);
return contracts;
} catch (error) {
throw this.httpErrorFactory.from(error);
}
}
}
18 changes: 18 additions & 0 deletions src/domain/data-decoder/v2/data-decoder.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Contract } from '@/domain/data-decoder/v2/entities/contract.entity';
import type { DataDecoded } from '@/domain/data-decoder/v2/entities/data-decoded.entity';
import type { Page } from '@/domain/entities/page.entity';

export const IDataDecoderRepository = Symbol('IDataDecoderRepository');

export interface IDataDecoderRepository {
getDecodedData(args: {
chainId: string;
data: `0x${string}`;
to: `0x${string}`;
}): Promise<DataDecoded>;

getContracts(args: {
chainId: string;
address: `0x${string}`;
}): Promise<Page<Contract>>;
}
16 changes: 16 additions & 0 deletions src/domain/data-decoder/v2/data-decoder.repository.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { DataDecoderApiManagerModule } from '@/datasources/data-decoder-api/data-decoder-api.manager.module';
import { DataDecoderRepository } from '@/domain/data-decoder/v2/data-decoder.repository';
import { IDataDecoderRepository } from '@/domain/data-decoder/v2/data-decoder.repository.interface';

@Module({
imports: [DataDecoderApiManagerModule],
providers: [
{
provide: IDataDecoderRepository,
useClass: DataDecoderRepository,
},
],
exports: [IDataDecoderRepository],
})
export class DataDecoderRepositoryModule {}
Loading

0 comments on commit 4b4cb62

Please sign in to comment.