Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(DTFS2-7121): create Companies House endpoint #821

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fe69ea3
feat(DTFS2-7121): add boilerplate
oscar-richardson-softwire Apr 30, 2024
32435da
refactor(DTFS2-7121): lint
oscar-richardson-softwire Apr 30, 2024
b920631
feat(DTFS2-7121): make the Companies House service call the API with …
oscar-richardson-softwire Apr 30, 2024
4070f6f
feat(DTFS2-7121): ensure Companies House service returns results when…
oscar-richardson-softwire Apr 30, 2024
8fa5ba9
refactor(DTFS2-7121): lint
oscar-richardson-softwire Apr 30, 2024
90b88f6
feat(DTFS2-7121): make Companies House service throw CompaniesHouseNo…
oscar-richardson-softwire Apr 30, 2024
8a21059
refactor(DTFS2-7121): lint
oscar-richardson-softwire Apr 30, 2024
36680e5
feat(DTFS2-7121): make Companies House service throw CompaniesHouseEx…
oscar-richardson-softwire Apr 30, 2024
cda53b0
feat(DTFS2-7121): make Companies House service throw a CompaniesHouse…
oscar-richardson-softwire Apr 30, 2024
9bf578a
feat(DTFS2-7121): make CH Service throw CompaniesHouseUnauthorizedExc…
oscar-richardson-softwire May 2, 2024
0f8777d
refactor(DTFS2-7121): lint
oscar-richardson-softwire May 2, 2024
12ec446
refactor(DTFS2-7121): correct name of API tests
oscar-richardson-softwire May 2, 2024
48549a7
fix(DTFS2-7121): fix CH Service 'Not Found' error handling - use HTTP…
oscar-richardson-softwire May 2, 2024
2f6262a
fix(DTFS2-7121): fix CH Service 400 and 401 error handling - use HTTP…
oscar-richardson-softwire May 2, 2024
9702c03
feat(DTFS2-7121): make Companies Service return a reduced form of com…
oscar-richardson-softwire May 2, 2024
47a6277
refactor(DTFS2-7121): tidy up Companies Service
oscar-richardson-softwire May 3, 2024
ec78f38
refactor(DTFS2-7121): tidy up Companies Service tests
oscar-richardson-softwire May 3, 2024
4605ade
feat(DTFS2-7121): add unit tests for Companies Controller
oscar-richardson-softwire May 3, 2024
a80c500
feat(DTFS2-7121): add API test for 200
oscar-richardson-softwire May 3, 2024
aac197a
feat(DTFS2-7121): add API test for client 400
oscar-richardson-softwire May 3, 2024
ab9751c
refactor(DTFS2-7121): refactor tests to use generator instead of JSON…
oscar-richardson-softwire May 3, 2024
d9f603d
feat(DTFS2-7121): add API tests for Companies House API error responses
oscar-richardson-softwire May 3, 2024
059feab
feat(DTFS2-7121): add SIC code mapping to /companies endpoint
oscar-richardson-softwire May 15, 2024
817656c
fix(DTFS2-7121): add proper error handling for 404 in response to PR …
oscar-richardson-softwire May 17, 2024
8830f45
refactor(DTFS2-7121): move one-time test setup into beforeAll blocks …
oscar-richardson-softwire May 17, 2024
ca76185
refactor(DTFS2-7121): add constants in response to PR comments
oscar-richardson-softwire May 17, 2024
d79f7b7
refactor(DTFS2-7121): lint
oscar-richardson-softwire May 17, 2024
61b33d7
feat(DTFS2-7121): add overseas company error handling in response to …
oscar-richardson-softwire May 20, 2024
dcc4b97
refactor(DTFS2-7121): lint
oscar-richardson-softwire May 20, 2024
3001419
refactor(DTFS2-7121): improve typing on shuffleArray function
oscar-richardson-softwire May 20, 2024
42015f3
refactor(DTFS2-7121): add 422 Swagger documentation
oscar-richardson-softwire May 22, 2024
08388db
refactor(DTFS2-7121): lint
oscar-richardson-softwire May 22, 2024
f4ca3ad
fix(DTFS2-7121): correct typo in CHANGELOG.md causing cspell pipeline…
oscar-richardson-softwire May 24, 2024
99287e1
feat(DTFS2-7121): change response format to make mapping in DTFS easier
oscar-richardson-softwire May 30, 2024
f5be7ae
refactor(DTFS2-7121): use for...of loop in shuffleArray function
oscar-richardson-softwire May 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"npmrc",
"NVARCHAR",
"osgb",
"pscs",
"pino",
"pinojs",
"satify",
Expand Down
6 changes: 6 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ ORDNANCE_SURVEY_URL=https://api.os.uk
ORDNANCE_SURVEY_KEY=
ORDNANCE_SURVEY_MAX_REDIRECTS=
ORDNANCE_SURVEY_TIMEOUT= # in milliseconds

# COMPANIES HOUSE
COMPANIES_HOUSE_URL=
COMPANIES_HOUSE_KEY=
COMPANIES_HOUSE_MAX_REDIRECTS=
COMPANIES_HOUSE_TIMEOUT= # in milliseconds
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* **DTFS2-7052:** actioning PR comments ([e012eb4](https://github.com/UK-Export-Finance/mdm-api/commit/e012eb4a7912dcb254bfa0e7c2f5d2794f11ab58))
* **DTFS2-7052:** actioning PR comments ([7d15b07](https://github.com/UK-Export-Finance/mdm-api/commit/7d15b07ef4126f99ffcf6189deac2b2391633edd))
* **DTFS2-7052:** adding constants and examples ([a3d5433](https://github.com/UK-Export-Finance/mdm-api/commit/a3d54338610f50f4824d6c6eeffa50ebdb6e91a2))
* **DTFS2-7052:** adding typescript include for json files, to satify lint. I added big examples to json files ([0b79772](https://github.com/UK-Export-Finance/mdm-api/commit/0b79772256a36fc8e5c9b38e3f67a0433984f2ab))
* **DTFS2-7052:** adding typescript include for json files, to satisfy lint. I added big examples to json files ([0b79772](https://github.com/UK-Export-Finance/mdm-api/commit/0b79772256a36fc8e5c9b38e3f67a0433984f2ab))
* **DTFS2-7052:** api-tests for geospatial/get-address-by-postcode ([c8cb1bc](https://github.com/UK-Export-Finance/mdm-api/commit/c8cb1bc98c72f3258f8bc9d498effdfe561ff45b))
* **DTFS2-7052:** applying Oscars suggestions on my PR ([f68ac66](https://github.com/UK-Export-Finance/mdm-api/commit/f68ac66050ea3e0515378657f4893eb65f933ed4))
* **DTFS2-7052:** change GET /geospatial/addresses/postcode?postcode= empty response from 200 to 404 ([33c9e65](https://github.com/UK-Export-Finance/mdm-api/commit/33c9e65961e67fdff36c854a7ae9f78c3c1ccd0c))
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ services:
ORDNANCE_SURVEY_KEY:
ORDNANCE_SURVEY_MAX_REDIRECTS:
ORDNANCE_SURVEY_TIMEOUT:
COMPANIES_HOUSE_URL:
COMPANIES_HOUSE_KEY:
COMPANIES_HOUSE_MAX_REDIRECTS:
COMPANIES_HOUSE_TIMEOUT:
API_KEY:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}"]
Expand Down
39 changes: 39 additions & 0 deletions src/config/companies-house.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { withEnvironmentVariableParsingUnitTests } from '@ukef-test/common-tests/environment-variable-parsing-unit-tests';

import companiesHouseConfig, { CompaniesHouseConfig } from './companies-house.config';

describe('companiesHouseConfig', () => {
const configDirectlyFromEnvironmentVariables: { configPropertyName: keyof CompaniesHouseConfig; environmentVariableName: string }[] = [
{
configPropertyName: 'baseUrl',
environmentVariableName: 'COMPANIES_HOUSE_URL',
},
{
configPropertyName: 'key',
environmentVariableName: 'COMPANIES_HOUSE_KEY',
},
];

const configParsedAsIntFromEnvironmentVariablesWithDefault: {
configPropertyName: keyof CompaniesHouseConfig;
environmentVariableName: string;
defaultConfigValue: number;
}[] = [
{
configPropertyName: 'maxRedirects',
environmentVariableName: 'COMPANIES_HOUSE_MAX_REDIRECTS',
defaultConfigValue: 5,
},
{
configPropertyName: 'timeout',
environmentVariableName: 'COMPANIES_HOUSE_TIMEOUT',
defaultConfigValue: 30000,
},
];

withEnvironmentVariableParsingUnitTests({
configDirectlyFromEnvironmentVariables,
configParsedAsIntFromEnvironmentVariablesWithDefault,
getConfig: () => companiesHouseConfig(),
});
});
20 changes: 20 additions & 0 deletions src/config/companies-house.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
import { COMPANIES_HOUSE } from '@ukef/constants';
import { getIntConfig } from '@ukef/helpers/get-int-config';

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

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

export default [AppConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
5 changes: 5 additions & 0 deletions src/constants/companies-house.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const COMPANIES_HOUSE = {
CONFIG: {
KEY: 'companiesHouse',
},
};
10 changes: 10 additions & 0 deletions src/constants/companies.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const COMPANIES = {
ENDPOINT_BASE_URL: '/api/v1/companies?registrationNumber=',
EXAMPLES: {
COMPANIES_HOUSE_REGISTRATION_NUMBER: '00000001',
},
REGEX: {
// This Companies House registration number regex was copied from the DTFS codebase.
COMPANIES_HOUSE_REGISTRATION_NUMBER: /^(([A-Z]{2}|[A-Z]\d|\d{2})(\d{5,6}|\d{4,5}[A-Z]))$/,
},
};
4 changes: 4 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
* 6. Strings to redact
* 7. Strings locations to redact
* 8. Module geospatial
* 9. Companies module
* 10. Companies House helper module
*/

export * from './auth.constant';
export * from './companies.constant';
export * from './companies-house.constant';
export * from './customers.constant';
export * from './database-name.constant';
export * from './date.constant';
Expand Down
27 changes: 27 additions & 0 deletions src/helper-modules/companies-house/companies-house.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 { CompaniesHouseConfig } from '@ukef/config/companies-house.config';
import { COMPANIES_HOUSE } from '@ukef/constants';
import { HttpModule } from '@ukef/modules/http/http.module';

import { CompaniesHouseService } from './companies-house.service';

@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { baseUrl, maxRedirects, timeout } = configService.get<CompaniesHouseConfig>(COMPANIES_HOUSE.CONFIG.KEY);
return {
baseURL: baseUrl,
maxRedirects,
timeout,
};
},
}),
],
providers: [CompaniesHouseService],
exports: [CompaniesHouseService],
})
export class CompaniesHouseModule {}
169 changes: 169 additions & 0 deletions src/helper-modules/companies-house/companies-house.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator';
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import { AxiosError } from 'axios';
import { resetAllWhenMocks, when } from 'jest-when';
import { of, throwError } from 'rxjs';

import { CompaniesHouseService } from './companies-house.service';
import { CompaniesHouseException } from './exception/companies-house.exception';
import { CompaniesHouseInvalidAuthorizationException } from './exception/companies-house-invalid-authorization.exception';
import { CompaniesHouseMalformedAuthorizationHeaderException } from './exception/companies-house-malformed-authorization-header.exception';
import { CompaniesHouseNotFoundException } from './exception/companies-house-not-found.exception';

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

const valueGenerator = new RandomValueGenerator();

const testRegistrationNumber = '00000001';

const {
companiesHousePath,
getCompanyCompaniesHouseResponse,
getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse,
getCompanyCompaniesHouseInvalidAuthorizationResponse,
getCompanyCompaniesHouseNotFoundResponse,
} = new GetCompanyGenerator(valueGenerator).generate({
numberToGenerate: 1,
registrationNumber: testRegistrationNumber,
});

const testKey = valueGenerator.string({ length: 40 });
const encodedTestKey = Buffer.from(testKey).toString('base64');

const expectedHttpServiceGetArguments: [string, object] = [
companiesHousePath,
{
headers: {
Authorization: `Basic ${encodedTestKey}`,
},
},
];

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

beforeAll(() => {
const httpService = new HttpService();
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved
httpServiceGet = jest.fn();
httpService.get = httpServiceGet;

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

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

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

describe('getCompanyByRegistrationNumber', () => {
it('calls the Companies House API with the correct arguments', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

await service.getCompanyByRegistrationNumber(testRegistrationNumber);

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

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

const response = await service.getCompanyByRegistrationNumber(testRegistrationNumber);

expect(response).toBe(getCompanyCompaniesHouseResponse);
});

it(`throws a CompaniesHouseMalformedAuthorizationHeaderException when the Companies House API returns a 400 response containing the error string 'Invalid Authorization header'`, async () => {
oscar-richardson-softwire marked this conversation as resolved.
Show resolved Hide resolved
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse,
status: 400,
statusText: 'Bad Request',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseMalformedAuthorizationHeaderException);
await expect(getCompanyPromise).rejects.toThrow(`Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`);
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

it(`throws a CompaniesHouseInvalidAuthorizationException when the Companies House API returns a 401 response containing the error string 'Invalid Authorization'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseInvalidAuthorizationResponse,
status: 401,
statusText: 'Unauthorized',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseInvalidAuthorizationException);
await expect(getCompanyPromise).rejects.toThrow('Invalid authorization. Check your Companies House API key.');
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

it(`throws a CompaniesHouseNotFoundException when the Companies House API returns a 404 response containing the error string 'company-profile-not-found'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseNotFoundResponse,
status: 404,
statusText: 'Not Found',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseNotFoundException);
await expect(getCompanyPromise).rejects.toThrow(`Company with registration number ${testRegistrationNumber} was not found.`);
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

it('throws a CompaniesHouseException if the Companies House API returns an unknown error response', async () => {
oscar-richardson-softwire marked this conversation as resolved.
Show resolved Hide resolved
const axiosError = new AxiosError();
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseException);
await expect(getCompanyPromise).rejects.toThrow('Failed to get response from Companies House API.');
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});
});
});
48 changes: 48 additions & 0 deletions src/helper-modules/companies-house/companies-house.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CompaniesHouseConfig } from '@ukef/config/companies-house.config';
import { COMPANIES_HOUSE } from '@ukef/constants';
import { HttpClient } from '@ukef/modules/http/http.client';

import { GetCompanyCompaniesHouseResponse } from './dto/get-company-companies-house-response.dto';
import {
getCompanyInvalidAuthorizationKnownCompaniesHouseError,
getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError,
getCompanyNotFoundKnownCompaniesHouseError,
} from './known-errors';
import { createWrapCompaniesHouseHttpGetErrorCallback } from './wrap-companies-house-http-error-callback';

@Injectable()
export class CompaniesHouseService {
private readonly httpClient: HttpClient;
private readonly key: string;

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

async getCompanyByRegistrationNumber(registrationNumber: string): Promise<GetCompanyCompaniesHouseResponse> {
oscar-richardson-softwire marked this conversation as resolved.
Show resolved Hide resolved
const path = `/company/${registrationNumber}`;
const encodedKey = Buffer.from(this.key).toString('base64');

const { data } = await this.httpClient.get<GetCompanyCompaniesHouseResponse>({
path,
headers: {
Authorization: `Basic ${encodedKey}`,
},
onError: createWrapCompaniesHouseHttpGetErrorCallback({
messageForUnknownError: 'Failed to get response from Companies House API.',
knownErrors: [
getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError(),
getCompanyInvalidAuthorizationKnownCompaniesHouseError(),
getCompanyNotFoundKnownCompaniesHouseError(registrationNumber),
],
}),
});

return data;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type GetCompanyCompaniesHouseErrorResponse = {
error: string;
type: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { GetCompanyCompaniesHouseErrorResponse } from './get-company-companies-house-error-response.dto';

export type GetCompanyCompaniesHouseMultipleErrorResponse = {
errors: GetCompanyCompaniesHouseErrorResponse[];
};
Loading
Loading