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(cb2-14934): create recalls brokering API #169

Merged
merged 39 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
be3c231
squash commits
naathanbrown Nov 14, 2024
ed959b1
feat(cb2-14394): start refactoring for caching
naathanbrown Nov 14, 2024
d67d78c
feat(cb2-14394): refactor handler file
naathanbrown Nov 14, 2024
02473bb
feat(cb2-14394): unit test single vin validator
naathanbrown Nov 14, 2024
8a3d3cc
feat(cb2-14394): test mot secret getting
naathanbrown Nov 14, 2024
a25c8de
feat(cb2-14394): change to main recalls handler
naathanbrown Nov 14, 2024
dba948c
feat(cb2-14394): add error log
naathanbrown Nov 14, 2024
bb4ed92
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
f868597
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
9dcc6d1
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
5e6449c
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
c62a57b
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
230c566
feat(cb2-14394): get token and call recalls
naathanbrown Nov 14, 2024
39f133b
feat(cb2-14394): update code to work without secrets manager vpc
naathanbrown Nov 15, 2024
c398215
feat(cb2-14394): update package json
naathanbrown Nov 15, 2024
3c3dac0
feat(cb2-14394): test filter MOT recalls funciton
naathanbrown Nov 15, 2024
7b30c8d
feat(CB2-14394): recalls handler unit test
m-mullen Nov 15, 2024
36130e6
feat(CB2-14394): recalls utils unit tests
m-mullen Nov 15, 2024
a177c37
Merge remote-tracking branch 'origin/develop' into feature/CB2-14394
naathanbrown Nov 19, 2024
8fed1b2
feat(cb2-14394): update docs
naathanbrown Nov 19, 2024
306e4e7
feat(cb2-14394): refactor and tidy
naathanbrown Nov 19, 2024
6b6c5c4
feat(cb2-14394): add happy path test
naathanbrown Nov 19, 2024
bdc6b49
feat(cb2-14934): run lint
naathanbrown Nov 19, 2024
2a3f934
feat(cb2-14934): run lint
naathanbrown Nov 19, 2024
3eb10f6
feat(cb2-14394): PR feedback
naathanbrown Nov 21, 2024
1c49084
feat(cb2-14394): alter return flow
naathanbrown Nov 22, 2024
e636fb0
feat(cb2-14394): improve debug log
naathanbrown Nov 22, 2024
089cbe2
feat(14394) update to x-api-key
JoshCarter-ops Nov 22, 2024
25fcdb5
feat: adding some more logging
JoshCarter-ops Nov 22, 2024
a76cc36
feat: adding more to debug logs
JoshCarter-ops Nov 22, 2024
3f3df34
feat: even more debug logs
JoshCarter-ops Nov 22, 2024
d7af96c
feat: checking allowlist
JoshCarter-ops Nov 22, 2024
eeca851
feat: test connection
JoshCarter-ops Nov 22, 2024
e0e3525
feat: revert extra debug logs
JoshCarter-ops Nov 22, 2024
451aafb
feat(cb2-14394): repair json call to response body
naathanbrown Nov 25, 2024
6f18b01
feat(cb2-14394): update to use secrets manager:
naathanbrown Nov 25, 2024
1e946c3
feat(cb2-14394): update to use secrets manager
naathanbrown Nov 25, 2024
c430b24
feat(cb2-14394): PR feedback
naathanbrown Nov 25, 2024
4c91a67
feat(cb2-14394): PR feedback
naathanbrown Nov 25, 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
27 changes: 27 additions & 0 deletions docs/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,33 @@ paths:
'404':
description: No records found matching identifier and criteria

'/v3/technical-records/recalls/{vin}':
get:
summary: 'Brokering API with MOT recalls API'
tags:
- Call MOT Recalls
parameters:
- in: path
name: vin
schema:
type: string
required: true
description: This represents the vin of the vehicle. Used to call MOT Recalls API.
responses:
'200':
description: Returns recall information
content:
application/json:
schema:
type: object
properties:
manufacturer:
type: string
hasRecall:
type: boolean
'500':
description: Internal server error

components:
securitySchemes:
OAuth2:
Expand Down
6,720 changes: 6,034 additions & 686 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@
],
"license": "MIT",
"dependencies": {
"@dvsa/cvs-type-definitions": "^7.6.3",
"@dvsa/aws-utilities": "^1.2.0",
"@dvsa/cvs-feature-flags": "^0.15.0",
"@dvsa/cvs-microservice-common": "^1.2.4",
"@dvsa/cvs-type-definitions": "^7.7.1",
"@types/luxon": "^3.3.0",
"jwt-decode": "^3.1.2",
"luxon": "^3.3.0",
"polly-js": "^1.8.3"
},
"devDependencies": {
"@aws-sdk/client-appconfigdata": "^3.693.0",
"@aws-sdk/client-dynamodb": "^3.359.0",
"@aws-sdk/client-lambda": "^3.362.0",
"@aws-sdk/client-s3": "^3.550.0",
Expand Down
79 changes: 79 additions & 0 deletions src/handler/motRecalls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { SecretsManager } from '@dvsa/aws-utilities/classes/secrets-manager-client';
import { EnvironmentVariables } from '@dvsa/cvs-microservice-common/classes/misc/env-vars';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { MotSecret } from '../models/motRecalls';
import { formatErrorMessage } from '../util/errorMessage';
import { addHttpHeaders } from '../util/httpHeaders';
import logger from '../util/logger';
import {
filterMotRecalls, getBearerToken, getMotRecallsByVin,
} from '../util/motRecalls';
import { validateFeatureFlags, validateSingleVin } from '../validators/motRecalls';

const cache: Map<string, (string | MotSecret)> = new Map();
const defaultResponse = addHttpHeaders({
statusCode: 200,
body: JSON.stringify({
manufacturer: null,
hasRecall: false,
}),
});

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
logger.info('Recalls end point called');
try {
const validateFeatureFlagsRecalls = await validateFeatureFlags();
if (validateFeatureFlagsRecalls) {
return validateFeatureFlagsRecalls;
}

const vin: string = decodeURIComponent(event.pathParameters?.vin as string);
if (!validateSingleVin(vin)) {
logger.error(formatErrorMessage('VIN provided in path parameter is not valid.'));
return defaultResponse;
}

const cachedMotSecret = cache.get('motSecret') as MotSecret;
const motSecret = cachedMotSecret ?? await SecretsManager.get(
{ SecretId: EnvironmentVariables.get('MOT_RECALL_SECRET') },
{},
{ fromYaml: true },
);

if (!cache.has('motSecret')) {
cache.set('motSecret', motSecret);
}

const cachedBearerToken = cache.get('bearerToken');
const bearerToken = cachedBearerToken ?? await getBearerToken(motSecret);

if (!bearerToken) {
logger.error('bearer token not found');
return defaultResponse;
}

if (!cache.has('bearerToken')) {
cache.set('bearerToken', bearerToken);
}

const recalls = await getMotRecallsByVin(vin, cache, motSecret);

if (!recalls) {
return defaultResponse;
}

const recallsResponse = filterMotRecalls(recalls);

logger.debug(`Final response: ${JSON.stringify(recallsResponse)}`);
return addHttpHeaders({
statusCode: 200,
body: JSON.stringify(recallsResponse),
});
} catch (err) {
console.error(err);
return addHttpHeaders({
statusCode: 500,
body: 'Error calling recalls API',
});
}
};
32 changes: 32 additions & 0 deletions src/models/motRecalls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface MotRecalls {
naathanbrown marked this conversation as resolved.
Show resolved Hide resolved
vin: string,
manufacturer: string,
recalls: Recall[],
lastUpdatedDate: string
}

export interface Recall {
manufacturerCampaignReference: string,
dvsaCampaignReference: string,
recallCampaignStartDate: string,
repairStatus: RepairStatus
}

export type RepairStatus = 'FIXED' | 'NOT_FIXED';

export interface MotSecret {
clientID: string,
clientSecret: string,
scopeURL: string,
accessTokenURL: string,
apiKey: string,
apiURL: string,
}

export interface BearerResponse {
token_type: string,
expires_in: number,
ext_expires_in: number,
access_token: string

}
112 changes: 112 additions & 0 deletions src/util/motRecalls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { RecallsSchema } from '@dvsa/cvs-type-definitions/types/v1/recalls';
import { BearerResponse, MotRecalls, MotSecret } from '../models/motRecalls';
import logger from './logger';

/**
* Search retrieved recall data for an active recall then construct return object.
* @param vehicleRecalls
* @returns
*/
export const filterMotRecalls = (vehicleRecalls: MotRecalls): RecallsSchema => {
logger.debug('Filter Recall Response');
const foundRecall = vehicleRecalls.recalls.find((recall) => {
if (recall.repairStatus === 'NOT_FIXED' && Date.parse(recall.recallCampaignStartDate) < Date.now()) {
return recall;
}
return false;
});
return {
manufacturer: foundRecall ? vehicleRecalls.manufacturer : null,
hasRecall: !!foundRecall,
};
};

/**
* Retrieve vehicle recall data from MOT recall API
* @param vin - vin is query parameter
* @param cache - the stored cache with string or mot secret
* @param motSecret - mot secret
* @returns Promise<motRecalls> - vehicle recall information
*/
export const getMotRecallsByVin = async (vin: string, cache: Map<string, (string | MotSecret)>, motSecret: MotSecret):
Promise<MotRecalls | undefined> => {
logger.debug('Calling MOT Recalls');

try {
const bearerToken = cache.get('bearerToken') as string;
const motApiUrl = `${motSecret.apiURL}/${vin}`;

let recallResponse = await fetch(motApiUrl, {
headers: {
Authorization: `Bearer ${bearerToken}`,
'x-api-key': motSecret.apiKey,
},
});

let parsedRecallResponse = await recallResponse.json() as MotRecalls;

logger.debug(`first recall status code: ${recallResponse.status}`);
logger.debug(`first recall response: ${JSON.stringify(parsedRecallResponse)}`);

if (recallResponse.status === 403 || recallResponse.status === 401) {
const newBearerToken = await getBearerToken(motSecret);
if (!newBearerToken) {
return undefined;
}
logger.debug('got a new bearer token');

cache.set('bearerToken', newBearerToken);
recallResponse = await fetch(motApiUrl, {
headers: {
Authorization: `Bearer ${bearerToken}`,
'x-api-key': motSecret.apiKey,
},
});

parsedRecallResponse = await recallResponse.json() as MotRecalls;

logger.debug(`second recall status code: ${recallResponse.status}`);
logger.debug(`second recall response: ${JSON.stringify(parsedRecallResponse)}`);
}

if (recallResponse.status === 200) {
return parsedRecallResponse;
}

return undefined;
} catch (err) {
logger.error(`failed calling MOT endpoint: Error: ${(err as Error).message}`);
return undefined;
}
};

/**
* Retrieve bearer token from MOT for recall API
* @param motSecret - the mot secret details
* @returns Promise<BearerToken> - JWT bearer token for recalls
*/
export const getBearerToken = async (motSecret: MotSecret): Promise<string | undefined> => {
logger.debug('Calling MOT Token');

const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', motSecret.clientID);
params.append('client_secret', motSecret.clientSecret);
params.append('scope', motSecret.scopeURL);

try {
const tokenResponse = await fetch(motSecret.accessTokenURL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});

const body = await tokenResponse.json() as BearerResponse;
return body.access_token;
} catch (err) {
logger.error(`Failed to get bearer token: Error: ${(err as Error).message}`);
return undefined;
}
};
52 changes: 52 additions & 0 deletions src/validators/motRecalls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getProfile } from '@dvsa/cvs-feature-flags/profiles/vtx';
import { addHttpHeaders } from '../util/httpHeaders';
import logger from '../util/logger';

/**
* validate the input vin has a valid format
* @param vin - query param
* @returns boolean - valid/invalid format
*/
export const validateSingleVin = (vin: string) => {
if (vin) {
if (vin.length < 3
|| vin.length > 21
|| typeof vin !== 'string'
|| !(/^[0-9a-z]+$/i).test(vin)
|| vin.toUpperCase().includes('O')
|| vin.toUpperCase().includes('I')
|| vin.toUpperCase().includes('Q')
) {
return false;
}
} else {
return false;
}
return true;
};

export const validateFeatureFlags = async () => {
logger.debug('validating the mot recalls feature flags');
const featureFlags = await getProfile();

if (!featureFlags.recallsApi) {
logger.error('Recall Feature Flag is undefined');
return addHttpHeaders({
statusCode: 500,
body: 'Recall Feature Flag is undefined',
});
}

if (!featureFlags.recallsApi.enabled) {
logger.warn('Flag disabled: please enable for recalls functionality');
return addHttpHeaders({
statusCode: 200,
body: JSON.stringify({
manufacturer: null,
hasRecall: false,
}),
});
}

return undefined;
};
13 changes: 13 additions & 0 deletions template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ Resources:
Properties:
Path: !GetAtt LocalQueue.Arn
BatchSize: 10

RecallsLambdaFunction:
Type: 'AWS::Serverless::Function'
Properties:
CodeUri: src/handler/
Handler: motRecalls.handler
Runtime: nodejs18.x
Events:
GetRecallsApi:
Type: Api
Properties:
Path: /v3/technical-records/recalls/{vin}
Method: get

MotUpdateVrm:
Type: 'AWS::Serverless::Function'
Expand Down
Loading
Loading