Skip to content

Commit

Permalink
feat: add DnB behaviour ready for use in customers service
Browse files Browse the repository at this point in the history
  • Loading branch information
Nat Dean-Lewis committed Nov 27, 2024
1 parent 0075701 commit 4cf255c
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 27 deletions.
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ services:
COMPANIES_HOUSE_KEY:
COMPANIES_HOUSE_MAX_REDIRECTS:
COMPANIES_HOUSE_TIMEOUT:
DUN_AND_BRADSTREET_URL:
DUN_AND_BRADSTREET_KEY:
DUN_AND_BRADSTREET_MAX_REDIRECTS:
DUN_AND_BRADSTREET_TIMEOUT:
API_KEY:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}"]
Expand Down
20 changes: 20 additions & 0 deletions src/config/dun-and-bradstreet.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { getIntConfig } from '@ukef/helpers/get-int-config';

export interface DunAndBradstreetConfig {
baseUrl: string;
key: string;
maxRedirects: number;
timeout: number;
}

export default registerAs(
DUN_AND_BRADSTREET.CONFIG.KEY,
(): DunAndBradstreetConfig => ({
baseUrl: process.env.DUN_AND_BRADSTREET_URL,
key: process.env.DUN_AND_BRADSTREET_KEY,
maxRedirects: getIntConfig(process.env.DUN_AND_BRADSTREET_MAX_REDIRECTS, 5),
timeout: getIntConfig(process.env.DUN_AND_BRADSTREET_TIMEOUT, 30000),
}),
);
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import AppConfig from './app.config';
import CompaniesHouseConfig from './companies-house.config';
import DatabaseConfig from './database.config';
import DocConfig from './doc.config';
import DunAndBradstreetConfig from './dun-and-bradstreet.config';
import InformaticaConfig from './informatica.config';
import OrdnanceSurveyConfig from './ordnance-survey.config';

export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, DunAndBradstreetConfig, InformaticaConfig, OrdnanceSurveyConfig];
5 changes: 5 additions & 0 deletions src/constants/dun-and-bradstreet.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const DUN_AND_BRADSTREET = {
CONFIG: {
KEY: 'dunAndBradstreet',
},
};
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './companies-house.constant';
export * from './customers.constant';
export * from './database-name.constant';
export * from './date.constant';
export * from './dun-and-bradstreet.constant';
export * from './enums';
export * from './geospatial.constant';
export * from './govuk-notify.constant';
Expand Down
27 changes: 27 additions & 0 deletions src/helper-modules/dun-and-bradstreet/dun-and-bradstreet.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpModule } from '@ukef/modules/http/http.module';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';

@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { baseUrl, maxRedirects, timeout } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
return {
baseURL: baseUrl,
maxRedirects,
timeout,
};
},
}),
],
providers: [DunAndBradstreetService],
exports: [DunAndBradstreetService],
})
export class DunAndBradstreetModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { resetAllWhenMocks, when } from 'jest-when';
import { of, throwError } from 'rxjs';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';
import { DunAndBradstreetException } from './exception/dun-and-bradstreet.exception';
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import { AxiosError } from 'axios';

describe('CompaniesHouseService', () => {
let httpServiceGet: jest.Mock;
let configServiceGet: jest.Mock;
let service: DunAndBradstreetService;

const valueGenerator = new RandomValueGenerator();

const testRegistrationNumber = '0' + valueGenerator.stringOfNumericCharacters({ length: 7 });
const expectedAccessToken = 'TEST_ACCESS_TOKEN';
const getAccessTokenMethodMock = jest
.spyOn(DunAndBradstreetService.prototype as any, 'getAccessToken')
.mockImplementation(() => Promise.resolve(expectedAccessToken))

const dunAndBradstreetpath = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${testRegistrationNumber}`;
const expectedDunsNumber = "123456789"
const getDunsNumberDunAndBradstreetResponse = {
"matchCandidates": [
{
"organization": {
"duns": expectedDunsNumber
}
}
]
}


const expectedHttpServiceGetArguments: [string, object] = [
dunAndBradstreetpath,
{
headers: {
Authorization: `Bearer ${expectedAccessToken}`,
},
},
];

const expectedHttpServiceGetResponse = of({
data: getDunsNumberDunAndBradstreetResponse,
status: 200,
statusText: 'OK',
config: undefined,
headers: undefined,
});

beforeAll(() => {
const httpService = new HttpService();
httpServiceGet = jest.fn();
httpService.get = httpServiceGet;

const configService = new ConfigService();
configServiceGet = jest.fn().mockReturnValue({ key: "TEST API_KEY" });
configService.get = configServiceGet;

service = new DunAndBradstreetService(httpService, configService);
});

beforeEach(() => {
resetAllWhenMocks();
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getDunAndBradstreetNumberByRegistrationNumber', () => {
it('calls the Dun and Bradstreet API with the correct arguments', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledWith(...expectedHttpServiceGetArguments);
});

it('returns the results when the Dun and Bradstreet API returns a 200 response with results', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

const response = await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(response).toBe(expectedDunsNumber);
});

it('throws a DunAndBradstreetException if the Dun and Bradstreet API returns an unknown error response', async () => {
const axiosError = new AxiosError();
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getDunsNumberPromise = service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
await expect(getDunsNumberPromise).rejects.toBeInstanceOf(DunAndBradstreetException);
await expect(getDunsNumberPromise).rejects.toThrow('Failed to get response from Dun and Bradstreet API');
await expect(getDunsNumberPromise).rejects.toHaveProperty('innerError', axiosError);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpClient } from '@ukef/modules/http/http.client';

import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { createWrapDunAndBradstreetHttpGetErrorCallback } from './wrap-dun-and-bradstreet-http-error-callback';

@Injectable()
export class DunAndBradstreetService {
private readonly httpClient: HttpClient;
private readonly encoded_key: string;


constructor(httpService: HttpService, configService: ConfigService) {
this.httpClient = new HttpClient(httpService);
const { key } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
this.encoded_key = key;
}

async getDunAndBradstreetNumberByRegistrationNumber(registrationNumber: string): Promise<string> {
const path = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${registrationNumber}`;
const access_token = await this.getAccessToken();

const { data } = await this.httpClient.get<any>({
path,
headers: {
'Authorization': 'Bearer ' + access_token,
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get response from Dun and Bradstreet API',
knownErrors: [],
}),
});
return data?.matchCandidates[0]?.organization?.duns;
}

private async getAccessToken(): Promise<string> {
const path = '/v3/token'
const response = await this.httpClient.post<any, any>({
path,
body: {
'grant_type': 'client_credentials',
},
headers: {
'Authorization': 'Basic ' + this.encoded_key,
'Content-Type': 'application/x-www-form-urlencoded',
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get access token',
knownErrors: [],
}),
})
return response.data.access_token
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';

import { DunAndBradstreetException } from './dun-and-bradstreet.exception';

describe('CompaniesHouseException', () => {
const valueGenerator = new RandomValueGenerator();
const message = valueGenerator.string();

it('exposes the message it was created with', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.message).toBe(message);
});

it('exposes the name of the exception', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.name).toBe('DunAndBradstreetException');
});

it('exposes the inner error it was created with', () => {
const innerError = new Error();

const exception = new DunAndBradstreetException(message, innerError);

expect(exception.innerError).toBe(innerError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class DunAndBradstreetException extends Error {
constructor(
message: string,
public readonly innerError?: Error,
) {
super(message);
this.name = this.constructor.name;
}
}
5 changes: 5 additions & 0 deletions src/helper-modules/dun-and-bradstreet/known-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AxiosError } from 'axios';

export type KnownErrors = KnownError[];

type KnownError = { checkHasError: (error: Error) => boolean; throwError: (error: AxiosError) => never };
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AxiosError } from 'axios';
import { ObservableInput, throwError } from 'rxjs';

import { DunAndBradstreetException } from './exception/dun-and-bradstreet.exception';
import { KnownErrors } from './known-errors';

type DunAndBradstreetHttpErrorCallback = (error: Error) => ObservableInput<never>;

export const createWrapDunAndBradstreetHttpGetErrorCallback =
({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): DunAndBradstreetHttpErrorCallback =>
(error: Error) => {
if (error instanceof AxiosError && error?.response) {
knownErrors.forEach(({ checkHasError, throwError }) => checkHasError(error) && throwError(error));
}

return throwError(() => new DunAndBradstreetException(messageForUnknownError, error));
};
6 changes: 6 additions & 0 deletions src/logging/log-keys-to-redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ describe('logKeysToRedact', () => {
clientRequest: {
logKey: valueGenerator.string(),
headersLogKey: valueGenerator.string(),
bodyLogKey: valueGenerator.string(),
},
outgoingRequest: {
logKey: valueGenerator.string(),
headersLogKey: valueGenerator.string(),
bodyLogKey: valueGenerator.string(),
},
incomingResponse: {
logKey: valueGenerator.string(),
bodyLogKey: valueGenerator.string(),
},
error: {
logKey: valueGenerator.string(),
Expand Down
22 changes: 19 additions & 3 deletions src/logging/log-keys-to-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ export interface LogKeysToRedactOptions {
clientRequest: {
logKey: string;
headersLogKey: string;
bodyLogKey: string;
};
outgoingRequest: {
logKey: string;
headersLogKey: string;
bodyLogKey: string;
};
incomingResponse: {
logKey: string;
bodyLogKey: string;
};

error: {
logKey: string;
sensitiveChildKeys: string[];
Expand All @@ -20,30 +27,39 @@ export interface LogKeysToRedactOptions {
};
}

export const logKeysToRedact = ({ redactLogs, clientRequest, outgoingRequest, error, dbError }: LogKeysToRedactOptions): string[] => {
export const logKeysToRedact = ({ redactLogs, clientRequest, outgoingRequest, incomingResponse, error, dbError }: LogKeysToRedactOptions): string[] => {
if (!redactLogs) {
return [];
}
const keys = [
...getClientRequestLogKeysToRedact(clientRequest),
...getOutgoingRequestLogKeysToRedact(outgoingRequest),
...getIncomingResponseLogKeysToRedact(incomingResponse),
...getErrorLogKeysToRedact(error),
...getDbErrorLogKeysToRedact(dbError),
];

return keys;
};

const getClientRequestLogKeysToRedact = ({ logKey, headersLogKey }: LogKeysToRedactOptions['clientRequest']): string[] => [
const getClientRequestLogKeysToRedact = ({ logKey, headersLogKey, bodyLogKey }: LogKeysToRedactOptions['clientRequest']): string[] => [
// We redact the client request headers as they contain the secret API key that the client uses to authenticate with our API.
buildKeyToRedact([logKey, headersLogKey]),
];

const getOutgoingRequestLogKeysToRedact = ({ logKey, headersLogKey }: LogKeysToRedactOptions['outgoingRequest']): string[] => {
const getIncomingResponseLogKeysToRedact = ({ logKey, bodyLogKey }: LogKeysToRedactOptions['incomingResponse']): string[] => [
// We redact the client request body as they contain the Dun and Bradstreet access token
buildKeyToRedact([logKey, bodyLogKey]),
];

const getOutgoingRequestLogKeysToRedact = ({ logKey, headersLogKey, bodyLogKey }: LogKeysToRedactOptions['outgoingRequest']): string[] => {
return [
// We redact the outgoing request headers as they contain:
// - our Basic auth details for Informatica
// We redact the outgoing request body as it contains:
// - our Client auth details for Dun and Bradstreet
buildKeyToRedact([logKey, headersLogKey]),
buildKeyToRedact([logKey, bodyLogKey]),
];
};

Expand Down
Loading

0 comments on commit 4cf255c

Please sign in to comment.