From e7ea643458084473871b7bc54273e951af6fff4d Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 26 Sep 2025 14:34:22 +0100 Subject: [PATCH 01/31] Change env var name --- infrastructure/terraform/components/api/locals.tf | 2 +- .../terraform/components/api/module_lambda_get_letters.tf | 2 +- .../terraform/components/api/module_lambda_patch_letters.tf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 1ee9232f..d69dbac9 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -14,7 +14,7 @@ locals { destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" - common_db_access_lambda_env_vars = { + common_lambda_env_vars = { LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, LETTER_TTL_HOURS = 24, SUPPLIER_ID_HEADER = "nhsd-supplier-id" diff --git a/infrastructure/terraform/components/api/module_lambda_get_letters.tf b/infrastructure/terraform/components/api/module_lambda_get_letters.tf index d654fa17..2695a8f8 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letters.tf @@ -35,7 +35,7 @@ module "get_letters" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_db_access_lambda_env_vars, { + lambda_env_vars = merge(local.common_lambda_env_vars, { MAX_LIMIT = var.max_get_limit }) } diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf b/infrastructure/terraform/components/api/module_lambda_patch_letters.tf index c9938857..3dd83a10 100644 --- a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_patch_letters.tf @@ -35,7 +35,7 @@ module "patch_letters" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_db_access_lambda_env_vars, {}) + lambda_env_vars = merge(local.common_lambda_env_vars, {}) } data "aws_iam_policy_document" "patch_letters_lambda" { From 6f006a7ab2d163f27b6444a39db86b72dafc34dd Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 29 Sep 2025 17:31:04 +0100 Subject: [PATCH 02/31] fix env var name --- infrastructure/terraform/components/api/locals.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index d69dbac9..8d992563 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -18,6 +18,6 @@ locals { LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, LETTER_TTL_HOURS = 24, SUPPLIER_ID_HEADER = "nhsd-supplier-id" - SUPPLIER_ID_HEADER = "nhsd-correlation-id" + APIM_CORRELATION_HEADER = "nhsd-correlation-id" } } From c94892f3c76c61eb57d02db09b54922c466df9f8 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 2 Oct 2025 09:24:39 +0000 Subject: [PATCH 03/31] Refactor contracts and mappers --- .../src/__test__/letter-repository.test.ts | 41 ++++++++-- internal/datastore/src/letter-repository.ts | 29 +++---- lambdas/api-handler/src/contracts/json-api.ts | 9 +++ .../api-handler/src/contracts/letter-api.ts | 48 ------------ lambdas/api-handler/src/contracts/letters.ts | 69 +++++++++++++++++ .../handlers/__tests__/get-letters.test.ts | 4 +- .../handlers/__tests__/patch-letters.test.ts | 73 ++++++++++-------- .../api-handler/src/handlers/get-letters.ts | 7 +- .../api-handler/src/handlers/patch-letters.ts | 9 ++- .../mappers/__tests__/letter-mapper.test.ts | 76 +++++++------------ .../api-handler/src/mappers/letter-mapper.ts | 52 +++++++++---- .../__tests__/letter-operations.test.ts | 47 +++++++----- .../src/services/letter-operations.ts | 13 ++-- 13 files changed, 276 insertions(+), 201 deletions(-) create mode 100644 lambdas/api-handler/src/contracts/json-api.ts delete mode 100644 lambdas/api-handler/src/contracts/letter-api.ts create mode 100644 lambdas/api-handler/src/contracts/letters.ts diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 19008447..cd7da96f 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -4,6 +4,7 @@ import { Letter } from '../types'; import { Logger } from 'pino'; import { createTestLogger, LogStream } from './logs'; import { PutCommand } from '@aws-sdk/lib-dynamodb'; +import { LetterDto } from '../../../../lambdas/api-handler/src/contracts/letters'; function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit { return { @@ -106,7 +107,14 @@ describe('LetterRepository', () => { await letterRepository.putLetter(letter); await checkLetterStatus('supplier1', 'letter1', 'PENDING'); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'REJECTED', 1, "Reason text"); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'REJECTED', + reasonCode: 1, + reasonText: 'Reason text' + }; + await letterRepository.updateLetterStatus(letterDto); const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); expect(updatedLetter.status).toBe('REJECTED'); @@ -124,13 +132,25 @@ describe('LetterRepository', () => { // Month is zero-indexed in JavaScript Date // Day is one-indexed jest.setSystemTime(new Date(2020, 1, 2)); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + + await letterRepository.updateLetterStatus(letterDto); const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); + expect(updatedLetter.updatedAt).toBe('2020-02-02T00:00:00.000Z'); }); test('can\'t update a letter that does not exist', async () => { - await expect(letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined)) + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await expect(letterRepository.updateLetterStatus(letterDto)) .rejects.toThrow('Letter with id letter1 not found for supplier supplier1'); }); @@ -139,7 +159,13 @@ describe('LetterRepository', () => { ...db.config, lettersTableName: 'nonexistent-table' }); - await expect(misconfiguredRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined)) + + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await expect(misconfiguredRepository.updateLetterStatus(letterDto)) .rejects.toThrow('Cannot do operations on a non-existent table'); }); @@ -164,7 +190,12 @@ describe('LetterRepository', () => { const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); expect(pendingLetters.letters).toHaveLength(2); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await letterRepository.updateLetterStatus(letterDto); const remainingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); expect(remainingLetters.letters).toHaveLength(1); expect(remainingLetters.letters[0].id).toBe('letter2'); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index aa379aed..71d1e63c 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -10,6 +10,7 @@ import { import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from './types'; import { Logger } from 'pino'; import { z } from 'zod'; +import { LetterDto } from '../../../lambdas/api-handler/src/contracts/letters'; export type PagingOptions = Partial<{ exclusiveStartKey: Record, @@ -133,37 +134,37 @@ export class LetterRepository { } } - async updateLetterStatus(supplierId: string, letterId: string, status: Letter['status'], reasonCode: number | undefined, reasonText: string | undefined): Promise { - this.log.debug(`Updating letter ${letterId} to status ${status}`); + async updateLetterStatus(letterToUpdate: LetterDto): Promise { + this.log.debug(`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); let result: UpdateCommandOutput; try { let updateExpression = 'set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl'; let expressionAttributeValues = { - ':status': status, + ':status': letterToUpdate.status, ':updatedAt': new Date().toISOString(), - ':supplierStatus': `${supplierId}#${status}`, + ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours), - ...(!reasonCode && {':reasonCode': reasonCode}), - ...(!reasonText && {':reasonText': reasonText}) + ...(!letterToUpdate.reasonCode && {':reasonCode': letterToUpdate.reasonCode}), + ...(!letterToUpdate.reasonText && {':reasonText': letterToUpdate.reasonText}) }; - if (reasonCode) + if (letterToUpdate.reasonCode) { updateExpression += ', reasonCode = :reasonCode'; - expressionAttributeValues[':reasonCode'] = reasonCode; + expressionAttributeValues[':reasonCode'] = letterToUpdate.reasonCode; } - if (reasonText) + if (letterToUpdate.reasonText) { updateExpression += ', reasonText = :reasonText'; - expressionAttributeValues[':reasonText'] = reasonText; + expressionAttributeValues[':reasonText'] = letterToUpdate.reasonText; } result = await this.ddbClient.send(new UpdateCommand({ TableName: this.config.lettersTableName, Key: { - supplierId: supplierId, - id: letterId + supplierId: letterToUpdate.supplierId, + id: letterToUpdate.id }, UpdateExpression: updateExpression, ConditionExpression: 'attribute_exists(id)', // Ensure letter exists @@ -176,12 +177,12 @@ export class LetterRepository { })); } catch (error) { if (error instanceof Error && error.name === 'ConditionalCheckFailedException') { - throw new Error(`Letter with id ${letterId} not found for supplier ${supplierId}`); + throw new Error(`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`); } throw error; } - this.log.debug(`Updated letter ${letterId} to status ${status}`); + this.log.debug(`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); return LetterSchema.parse(result.Attributes); } diff --git a/lambdas/api-handler/src/contracts/json-api.ts b/lambdas/api-handler/src/contracts/json-api.ts new file mode 100644 index 00000000..46607d29 --- /dev/null +++ b/lambdas/api-handler/src/contracts/json-api.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// Single document wrapper +export const makeDocumentSchema = (resourceSchema: T) => + z.object({ data: resourceSchema }).strict(); + +// Collection document wrapper +export const makeCollectionSchema = (resourceSchema: T) => + z.object({ data: z.array(resourceSchema) }).strict(); diff --git a/lambdas/api-handler/src/contracts/letter-api.ts b/lambdas/api-handler/src/contracts/letter-api.ts deleted file mode 100644 index ec3c8647..00000000 --- a/lambdas/api-handler/src/contracts/letter-api.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; - -export const LetterApiStatusSchema = z.enum([ - "PENDING", - "ACCEPTED", - "REJECTED", - "PRINTED", - "ENCLOSED", - "CANCELLED", - "DISPATCHED", - "FAILED", - "RETURNED", - "DESTROYED", - "FORWARDED", - "DELIVERED", -]); - -export type LetterApiStatus = z.infer; - -export const LetterApiAttributesSchema = z.object({ - status: LetterApiStatusSchema, - specificationId: z.string(), - groupId: z.string().optional(), - reasonCode: z.number().optional(), - reasonText: z.string().optional(), -}); - -export type LetterApiAttributes = z.infer; - -export const LetterApiResourceSchema = z.object({ - id: z.string(), - type: z.literal("Letter"), - attributes: LetterApiAttributesSchema -}); - -export type LetterApiResource = z.infer; - -export const LetterApiDocumentSchema = z.object({ - data: LetterApiResourceSchema -}); - -export const LettersApiDocumentSchema = z.object({ - data: z.array(LetterApiResourceSchema) -}); - -export type LetterApiDocument = z.infer; - -export type LettersApiDocument = z.infer; diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts new file mode 100644 index 00000000..26672c14 --- /dev/null +++ b/lambdas/api-handler/src/contracts/letters.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { makeCollectionSchema, makeDocumentSchema } from './json-api'; + +export type LetterDto = { + id: string, + status: LetterStatus, + supplierId: string, + specificationId?: string, + groupId?: string, + reasonCode?: number, + reasonText?: string +}; + +export const LetterStatusSchema = z.enum([ + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'PRINTED', + 'ENCLOSED', + 'CANCELLED', + 'DISPATCHED', + 'FAILED', + 'RETURNED', + 'DESTROYED', + 'FORWARDED', + 'DELIVERED' +]); + +export const PatchLetterRequestResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + reasonCode: z.number().optional(), + reasonText: z.string().optional(), + }).strict() +}).strict(); + +export const PatchLetterResponseResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + specificationId: z.string(), + groupId: z.string().optional(), + reasonCode: z.number().optional(), + reasonText: z.string().optional(), + }).strict() +}).strict(); + +export const GetLettersResponseResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + specificationId: z.string(), + groupId: z.string().optional(), + }).strict() +}).strict(); + +export type LetterStatus = z.infer; + +export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema); +export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema); +export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema); + +export type PatchLetterRequest = z.infer; +export type PatchLetterResponse = z.infer; +export type GetLettersResponse = z.infer; diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index 7a0d1baa..a1ef63a1 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -65,8 +65,8 @@ describe('API Lambda handler', () => { specificationId: "s1", groupId: 'g1', status: "PENDING", - reasonCode: 123, - reasonText: "Reason text" + reasonCode: 123, // shouldn't be returned if present + reasonText: "Reason text" // shouldn't be returned if present }, ]); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts index c649d6fa..7a271b2c 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts @@ -3,7 +3,7 @@ import { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; import { makeApiGwEvent } from './utils/test-utils'; import * as letterService from '../../services/letter-operations'; -import { LetterApiDocument, LetterApiStatus } from '../../contracts/letter-api'; +import { PatchLetterRequest, PatchLetterResponse } from '../../contracts/letters'; import { mapErrorToResponse } from '../../mappers/error-mapper'; import { ValidationError } from '../../errors'; import * as errors from '../../contracts/errors'; @@ -11,9 +11,9 @@ import * as errors from '../../contracts/errors'; jest.mock('../../services/letter-operations'); jest.mock('../../mappers/error-mapper'); -jest.mock("../../config/lambda-config", () => ({ +jest.mock('../../config/lambda-config', () => ({ lambdaConfig: { - SUPPLIER_ID_HEADER: "nhsd-supplier-id", + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id' } })); @@ -21,29 +21,25 @@ jest.mock("../../config/lambda-config", () => ({ const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, - body: "Error" + body: 'Error' }; mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); const mockedPatchLetterStatus = jest.mocked(letterService.patchLetterStatus); -const letterApiDocument = makeLetterApiDocument("id1", "REJECTED"); -const requestBody = JSON.stringify(letterApiDocument, null, 2); - -function makeLetterApiDocument(id: string, status: LetterApiStatus) : LetterApiDocument { - return { +const updateLetterStatusRequest : PatchLetterRequest = { data: { + id: 'id1', + type: 'Letter', attributes: { + status: 'REJECTED', reasonCode: 123, - reasonText: "Reason text", - specificationId: "spec1", - status - }, - id, - type: "Letter" + reasonText: 'Reason text', + } } - }; -} +}; + +const requestBody = JSON.stringify(updateLetterStatusRequest, null, 2); beforeEach(() => { jest.clearAllMocks(); @@ -55,26 +51,39 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - mockedPatchLetterStatus.mockResolvedValue(letterApiDocument); + const updateLetterServiceResponse : PatchLetterResponse = { + data: { + id: 'id1', + type: 'Letter', + attributes: { + status: 'REJECTED', + specificationId: 'spec1', + groupId: 'group1', + reasonCode: 123, + reasonText: 'Reason text', + } + } + }; + mockedPatchLetterStatus.mockResolvedValue(updateLetterServiceResponse); const result = await patchLetters(event, context, callback); expect(result).toEqual({ statusCode: 200, - body: requestBody, + body: JSON.stringify(updateLetterServiceResponse, null, 2) }); }); it('returns error response when there is no body', async () => { const event = makeApiGwEvent({ path: '/letters/id1', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); @@ -107,7 +116,7 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); @@ -123,7 +132,7 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); @@ -138,8 +147,8 @@ describe('patchLetters API Handler', () => { it('returns error when request body does not have correct shape', async () => { const event = makeApiGwEvent({ path: '/letters/id1', - body: '{test: "test"}', - pathParameters: {id: "id1"}, + body: "{test: 'test'}", + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); @@ -155,7 +164,7 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: '{#invalidJSON', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); @@ -171,14 +180,14 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: 'somebody', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const error = "Unexpected error"; - const spy = jest.spyOn(JSON, "parse").mockImplementation(() => { + const error = 'Unexpected error'; + const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => { throw error; }); @@ -190,11 +199,11 @@ describe('patchLetters API Handler', () => { spy.mockRestore(); }); - it("returns error if correlation id not provided in request", async () => { + it('returns error if correlation id not provided in request', async () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1'} }); const context = mockDeep(); @@ -210,7 +219,7 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {} }); const context = mockDeep(); diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 0c24ca4b..5a60f217 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,14 +1,13 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; import { getLettersForSupplier } from "../services/letter-operations"; import { createLetterRepository } from "../infrastructure/letter-repo-factory"; -import { LetterBase } from "../../../../internal/datastore/src"; import { assertNotEmpty } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { lambdaConfig } from "../config/lambda-config"; import pino from 'pino'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; -import { mapLetterBaseToApiResource } from "../mappers/letter-mapper"; +import { mapToGetLettersResponse } from "../mappers/letter-mapper"; const letterRepo = createLetterRepository(); @@ -39,9 +38,7 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { letterRepo, ); - const response = { - data: letters.map((letter: LetterBase) => (mapLetterBaseToApiResource(letter, { excludeOptional: true }))) - }; + const response = mapToGetLettersResponse(letters); log.info({ description: 'Pending letters successfully fetched', diff --git a/lambdas/api-handler/src/handlers/patch-letters.ts b/lambdas/api-handler/src/handlers/patch-letters.ts index 558ac7ec..0026345c 100644 --- a/lambdas/api-handler/src/handlers/patch-letters.ts +++ b/lambdas/api-handler/src/handlers/patch-letters.ts @@ -1,12 +1,13 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; import { createLetterRepository } from '../infrastructure/letter-repo-factory'; import { patchLetterStatus } from '../services/letter-operations'; -import { LetterApiDocument, LetterApiDocumentSchema } from '../contracts/letter-api'; +import { PatchLetterRequest, PatchLetterRequestSchema } from '../contracts/letters'; import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; import { lambdaConfig } from "../config/lambda-config"; import { assertNotEmpty } from '../utils/validation'; +import { mapToLetterDto } from '../mappers/letter-mapper'; const letterRepo = createLetterRepository(); export const patchLetters: APIGatewayProxyHandler = async (event) => { @@ -20,10 +21,10 @@ export const patchLetters: APIGatewayProxyHandler = async (event) => { const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); - let patchLetterRequest: LetterApiDocument; + let patchLetterRequest: PatchLetterRequest; try { - patchLetterRequest = LetterApiDocumentSchema.parse(JSON.parse(body)); + patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); } catch (error) { if (error instanceof Error) { throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); @@ -31,7 +32,7 @@ export const patchLetters: APIGatewayProxyHandler = async (event) => { else throw error; } - const result = await patchLetterStatus(patchLetterRequest.data, letterId!, supplierId!, letterRepo); + const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId!), letterId!, letterRepo); return { statusCode: 200, diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index 82adabd2..96548cda 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -1,9 +1,9 @@ -import { mapLetterBaseToApiDocument, mapLetterBaseToApiResource } from '../letter-mapper'; +import { mapToGetLettersResponse, mapToPatchLetterResponse } from '../letter-mapper'; import { Letter } from '../../../../../internal/datastore'; -import { LetterApiDocument, LetterApiResource } from '../../contracts/letter-api'; +import { GetLettersResponse, PatchLetterResponse } from '../../contracts/letters'; describe('letter-mapper', () => { - it('maps a Letter to LetterApiDocument', () => { + it('maps an internal Letter to a PatchLetterResponse', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -18,7 +18,7 @@ describe('letter-mapper', () => { ttl: 123 }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter); + const result: PatchLetterResponse = mapToPatchLetterResponse(letter); expect(result).toEqual({ data: { @@ -33,7 +33,7 @@ describe('letter-mapper', () => { }); }); - it('maps a Letter to LetterApiDocument with reasonCode and reasonText when present', () => { + it('maps an internal Letter to a PatchLetterResponse with reasonCode and reasonText when present', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -50,7 +50,7 @@ describe('letter-mapper', () => { reasonText: 'Reason text' }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter, {excludeOptional:false}); + const result: PatchLetterResponse = mapToPatchLetterResponse(letter); expect(result).toEqual({ data: { @@ -67,7 +67,7 @@ describe('letter-mapper', () => { }); }); - it('maps a Letter to LetterApiDocument without reasonCode and reasonText when present', () => { + it('maps an internal Letter collection to a GetLettersResponse', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -84,51 +84,29 @@ describe('letter-mapper', () => { reasonText: 'Reason text' }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter, {excludeOptional: true}); + const result: GetLettersResponse = mapToGetLettersResponse([letter, letter]); expect(result).toEqual({ - data: { - id: 'abc123', - type: 'Letter', - attributes: { - specificationId: 'spec123', - status: 'PENDING', - groupId: 'group123' + data: [ + { + id: 'abc123', + type: 'Letter', + attributes: { + specificationId: 'spec123', + status: 'PENDING', + groupId: 'group123' + } + }, + { + id: 'abc123', + type: 'Letter', + attributes: { + specificationId: 'spec123', + status: 'PENDING', + groupId: 'group123' + } } - } - }); - }); - - - it('maps a Letter to LetterApiResource with reasonCode and reasonText when present', () => { - const letter: Letter = { - id: 'abc123', - status: 'PENDING', - supplierId: 'supplier1', - specificationId: 'spec123', - groupId: 'group123', - url: 'https://example.com/letter/abc123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - supplierStatus: 'supplier1#PENDING', - supplierStatusSk: Date.now().toString(), - ttl: 123, - reasonCode: 123, - reasonText: 'Reason text' - }; - - const result: LetterApiResource = mapLetterBaseToApiResource(letter); - - expect(result).toEqual({ - id: 'abc123', - type: 'Letter', - attributes: { - specificationId: 'spec123', - status: 'PENDING', - groupId: 'group123', - reasonCode: 123, - reasonText: 'Reason text' - } + ] }); }); }); diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index c9c55ab2..14948842 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,22 +1,46 @@ -import { LetterBase } from "../../../../internal/datastore"; -import { LetterApiDocument, LetterApiDocumentSchema, LetterApiResource, LetterApiResourceSchema } from '../contracts/letter-api'; +import { LetterBase, LetterStatus } from "../../../../internal/datastore"; +import { GetLettersResponse, GetLettersResponseSchema, LetterDto, LetterStatusSchema, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters'; -export function mapLetterBaseToApiDocument(letterBase: LetterBase, opts: { excludeOptional: boolean } = { excludeOptional: false }): LetterApiDocument { - return LetterApiDocumentSchema.parse({ - data: mapLetterBaseToApiResource(letterBase, opts) +export function mapToLetterDto(request: PatchLetterRequest, supplierId: string) : LetterDto { + return { + id: request.data.id, + supplierId, + status: LetterStatus.parse(request.data.attributes.status), + reasonCode: request.data.attributes.reasonCode, + reasonText: request.data.attributes.reasonText, + }; +} + +export function mapToPatchLetterResponse(letter: LetterBase): PatchLetterResponse { + return PatchLetterResponseSchema.parse({ + data: { + id: letter.id, + type: 'Letter', + attributes: { + status: letter.status, + specificationId: letter.specificationId, + groupId: letter.groupId, + ...(letter.reasonCode != null && { reasonCode: letter.reasonCode }), + ...(letter.reasonText != null && { reasonText: letter.reasonText }) + } + } }); } -export function mapLetterBaseToApiResource(letterBase: LetterBase, opts: { excludeOptional: boolean } = { excludeOptional: false }): LetterApiResource { - return LetterApiResourceSchema.parse({ - id: letterBase.id, +export function mapToGetLettersResponse(letters: LetterBase[]): GetLettersResponse { + return GetLettersResponseSchema.parse({ + data: letters.map(letterToResourceResponse) + }); +} + +function letterToResourceResponse(letter: LetterBase) { + return { + id: letter.id, type: 'Letter', attributes: { - status: letterBase.status, - specificationId: letterBase.specificationId, - groupId: letterBase.groupId, - ...(letterBase.reasonCode && !opts.excludeOptional && { reasonCode: letterBase.reasonCode }), - ...(letterBase.reasonText && !opts.excludeOptional && { reasonText: letterBase.reasonText }) + status: letter.status, + specificationId: letter.specificationId, + groupId: letter.groupId } - }); + }; } diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 1a6f82f4..7420c311 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,20 +1,7 @@ import { Letter } from '../../../../../internal/datastore/src'; -import { LetterApiResource, LetterApiStatus } from '../../contracts/letter-api'; +import { LetterDto, LetterStatus } from '../../contracts/letters'; import { getLettersForSupplier, patchLetterStatus } from '../letter-operations'; -function makeLetterApiResource(id: string, status: LetterApiStatus) : LetterApiResource { - return { - attributes: { - specificationId: "spec123", - status, - groupId: 'group123', - reasonCode: 123, - reasonText: "Reason text" - }, - id, - type: "Letter" - }; -} function makeLetter(id: string, status: Letter['status']) : Letter { return { @@ -64,7 +51,13 @@ describe("getLetterIdsForSupplier", () => { describe('patchLetterStatus function', () => { - const letterResource = makeLetterApiResource("letter1", "REJECTED"); + const updatedLetterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'REJECTED', + reasonCode: 123, + reasonText: 'Reason text' + }; const updatedLetter = makeLetter("letter1", "REJECTED"); @@ -73,13 +66,25 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockResolvedValue(updatedLetter) }; - const result = await patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any); - - expect(result).toEqual({ data: letterResource}); + const result = await patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any); + + expect(result).toEqual({ data: + { + id: 'letter1', + type: 'Letter', + attributes: { + status: 'REJECTED', + reasonCode: updatedLetter.reasonCode, + reasonText: updatedLetter.reasonText, + specificationId: updatedLetter.specificationId, + groupId: updatedLetter.groupId + }, + } + }); }); it('should throw validationError when letterIds differ', async () => { - await expect(patchLetterStatus(letterResource, 'letter2', "supplier1", {} as any)).rejects.toThrow("The letter ID in the request body does not match the letter ID path parameter"); + await expect(patchLetterStatus(updatedLetterDto, 'letter2', {} as any)).rejects.toThrow("The letter ID in the request body does not match the letter ID path parameter"); }); it('should throw notFoundError when letter does not exist', async () => { @@ -87,7 +92,7 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1')) }; - await expect(patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any)).rejects.toThrow("No resource found with that ID"); + await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("No resource found with that ID"); }); it('should throw unexpected error', async () => { @@ -96,6 +101,6 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockRejectedValue(new Error('unexpected error')) }; - await expect(patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any)).rejects.toThrow("unexpected error"); + await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); }); }); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index c0104b94..ed66d252 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -1,7 +1,7 @@ -import { LetterBase, LetterRepository } from '../../../../internal/datastore/src' +import { Letter, LetterBase, LetterRepository } from '../../../../internal/datastore/src' import { NotFoundError, ValidationError } from '../errors'; -import { LetterApiResource, LetterApiDocument } from '../contracts/letter-api'; -import { mapLetterBaseToApiDocument } from '../mappers/letter-mapper'; +import { LetterDto, PatchLetterResponse } from '../contracts/letters'; +import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; import { ApiErrorDetail } from '../contracts/errors'; @@ -10,7 +10,7 @@ export const getLettersForSupplier = async (supplierId: string, status: string, return await letterRepo.getLettersBySupplier(supplierId, status, limit); } -export const patchLetterStatus = async (letterToUpdate: LetterApiResource, letterId: string, supplierId: string, letterRepo: LetterRepository): Promise => { +export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: string, letterRepo: LetterRepository): Promise => { if (letterToUpdate.id !== letterId) { throw new ValidationError(ApiErrorDetail.InvalidRequestLetterIdsMismatch); @@ -19,8 +19,7 @@ export const patchLetterStatus = async (letterToUpdate: LetterApiResource, lette let updatedLetter; try { - updatedLetter = await letterRepo.updateLetterStatus(supplierId, letterId, letterToUpdate.attributes.status, - letterToUpdate.attributes.reasonCode, letterToUpdate.attributes.reasonText); + updatedLetter = await letterRepo.updateLetterStatus(letterToUpdate); } catch (error) { if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); @@ -28,5 +27,5 @@ export const patchLetterStatus = async (letterToUpdate: LetterApiResource, lette throw error; } - return mapLetterBaseToApiDocument(updatedLetter); + return mapToPatchLetterResponse(updatedLetter); } From 989792fe73cca697050049dc5f6b85871334c015 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 2 Oct 2025 09:41:53 +0000 Subject: [PATCH 04/31] Fix invalid error message --- sandbox/api/openapi.yaml | 4 ++-- .../errors/responses/getLetter/limitInvalidValue.json | 2 +- .../examples/errors/responses/getLetter/unknownParameter.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml index 2390ee07..00ebe84d 100644 --- a/sandbox/api/openapi.yaml +++ b/sandbox/api/openapi.yaml @@ -213,7 +213,7 @@ paths: value: errors: - code: NOTIFY_INVALID_REQUEST - detail: "Invalid Request: Only 'limit' query parameter is supported" + detail: "Only 'limit' query parameter is supported" id: rrt-1931948104716186917-c-geu2-10664-3111479-3.0 links: about: https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier @@ -223,7 +223,7 @@ paths: value: errors: - code: NOTIFY_INVALID_REQUEST - detail: "Invalid Request: limit parameter must be a positive\ + detail: "The limit parameter must be a positive\ \ number not greater than 2500" id: rrt-1931948104716186917-c-geu2-10664-3111479-3.0 links: diff --git a/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json b/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json index 58277a5a..dfb7d3b2 100644 --- a/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json +++ b/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json @@ -2,7 +2,7 @@ "errors": [ { "code": "NOTIFY_INVALID_REQUEST", - "detail": "Invalid Request: limit parameter must be a positive number not greater than 2500", + "detail": "The limit parameter must be a positive number not greater than 2500", "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", "links": { "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier" diff --git a/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json b/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json index 036b8f56..ba156228 100644 --- a/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json +++ b/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json @@ -2,7 +2,7 @@ "errors": [ { "code": "NOTIFY_INVALID_REQUEST", - "detail": "Invalid Request: Only 'limit' query parameter is supported", + "detail": "Only 'limit' query parameter is supported", "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", "links": { "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier" From 681ee2b1db9ce71d4d97a66d0e19a1cc4c3a8088 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 2 Oct 2025 13:29:00 +0000 Subject: [PATCH 05/31] Clean up updateLetterStatus --- internal/datastore/src/letter-repository.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 71d1e63c..2afe0792 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -139,14 +139,12 @@ export class LetterRepository { let result: UpdateCommandOutput; try { let updateExpression = 'set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl'; - let expressionAttributeValues = { - ':status': letterToUpdate.status, - ':updatedAt': new Date().toISOString(), - ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, - ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours), - ...(!letterToUpdate.reasonCode && {':reasonCode': letterToUpdate.reasonCode}), - ...(!letterToUpdate.reasonText && {':reasonText': letterToUpdate.reasonText}) - }; + let expressionAttributeValues : Record = { + ':status': letterToUpdate.status, + ':updatedAt': new Date().toISOString(), + ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, + ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours) + }; if (letterToUpdate.reasonCode) { From 3a5da1379af094a64f6656475f9acb4d3329519c Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 3 Oct 2025 09:54:09 +0000 Subject: [PATCH 06/31] patchLetters to patchLetter --- .../terraform/components/api/README.md | 2 +- .../iam_role_api_gateway_execution_role.tf | 2 +- .../terraform/components/api/locals.tf | 2 +- ...tters.tf => module_lambda_patch_letter.tf} | 10 ++++---- .../components/api/resources/spec.tmpl.json | 4 ++-- ...h-letters.test.ts => patch-letter.test.ts} | 24 +++++++++---------- .../{patch-letters.ts => patch-letter.ts} | 2 +- lambdas/api-handler/src/index.ts | 2 +- postman/Sandbox.postman_collection.json | 4 ++-- sandbox/api/openapi.yaml | 6 ++--- sandbox/controllers/LetterController.js | 6 ++--- sandbox/services/LetterService.js | 8 +++---- sandbox/utils/ResponseProvider.js | 12 +++++----- .../api/components/endpoints/patchLetter.yml | 2 +- 14 files changed, 43 insertions(+), 43 deletions(-) rename infrastructure/terraform/components/api/{module_lambda_patch_letters.tf => module_lambda_patch_letter.tf} (88%) rename lambdas/api-handler/src/handlers/__tests__/{patch-letters.test.ts => patch-letter.test.ts} (90%) rename lambdas/api-handler/src/handlers/{patch-letters.ts => patch-letter.ts} (96%) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index c1a14fe9..fd16d4e6 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,7 @@ No requirements. | [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | -| [patch\_letters](#module\_patch\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | +| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-ssl.zip | n/a | ## Outputs diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf index d73e9201..4ceca3cd 100644 --- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf @@ -50,7 +50,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { resources = [ module.authorizer_lambda.function_arn, module.get_letters.function_arn, - module.patch_letters.function_arn + module.patch_letter.function_arn ] } } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 8d992563..982bc110 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -9,7 +9,7 @@ locals { AWS_REGION = var.region AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn - PATCH_LETTERS_LAMBDA_ARN = module.patch_letters.function_arn + PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn }) destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf similarity index 88% rename from infrastructure/terraform/components/api/module_lambda_patch_letters.tf rename to infrastructure/terraform/components/api/module_lambda_patch_letter.tf index 3dd83a10..568eff56 100644 --- a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf @@ -1,7 +1,7 @@ -module "patch_letters" { +module "patch_letter" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" - function_name = "patch_letters" + function_name = "patch_letter" description = "Update the status of a letter" aws_account_id = var.aws_account_id @@ -15,14 +15,14 @@ module "patch_letters" { kms_key_arn = module.kms.key_arn iam_policy_document = { - body = data.aws_iam_policy_document.patch_letters_lambda.json + body = data.aws_iam_policy_document.patch_letter_lambda.json } function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] function_code_base_path = local.aws_lambda_functions_dir_path function_code_dir = "api-handler/dist" function_include_common = true - handler_function_name = "patchLetters" + handler_function_name = "patchLetter" runtime = "nodejs22.x" memory = 128 timeout = 5 @@ -38,7 +38,7 @@ module "patch_letters" { lambda_env_vars = merge(local.common_lambda_env_vars, {}) } -data "aws_iam_policy_document" "patch_letters_lambda" { +data "aws_iam_policy_document" "patch_letter_lambda" { statement { sid = "KMSPermissions" effect = "Allow" diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 193d08d1..ec58b67e 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -67,7 +67,7 @@ ], "patch": { "description": "Update the status of a letter by providing the new status in the request body.", - "operationId": "patchLetters", + "operationId": "patchLetter", "requestBody": { "required": true }, @@ -102,7 +102,7 @@ }, "timeoutInMillis": 29000, "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTERS_LAMBDA_ARN}/invocations" + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations" } } } diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts similarity index 90% rename from lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts rename to lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 7a271b2c..83f122ec 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -1,4 +1,4 @@ -import { patchLetters } from '../../index'; +import { patchLetter } from '../../index'; import { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; import { makeApiGwEvent } from './utils/test-utils'; @@ -45,7 +45,7 @@ beforeEach(() => { jest.clearAllMocks(); }); -describe('patchLetters API Handler', () => { +describe('patchLetter API Handler', () => { it('returns 200 OK with updated resource', async () => { const event = makeApiGwEvent({ @@ -72,7 +72,7 @@ describe('patchLetters API Handler', () => { }; mockedPatchLetterStatus.mockResolvedValue(updateLetterServiceResponse); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(result).toEqual({ statusCode: 200, @@ -89,7 +89,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -103,7 +103,7 @@ describe('patchLetters API Handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -122,7 +122,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -138,7 +138,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -154,7 +154,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -170,7 +170,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -191,7 +191,7 @@ describe('patchLetters API Handler', () => { throw error; }); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -209,7 +209,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); expect(result).toEqual(expectedErrorResponse); @@ -225,7 +225,7 @@ describe('patchLetters API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); expect(result).toEqual(expectedErrorResponse); diff --git a/lambdas/api-handler/src/handlers/patch-letters.ts b/lambdas/api-handler/src/handlers/patch-letter.ts similarity index 96% rename from lambdas/api-handler/src/handlers/patch-letters.ts rename to lambdas/api-handler/src/handlers/patch-letter.ts index 0026345c..fa7babfc 100644 --- a/lambdas/api-handler/src/handlers/patch-letters.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -10,7 +10,7 @@ import { assertNotEmpty } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; const letterRepo = createLetterRepository(); -export const patchLetters: APIGatewayProxyHandler = async (event) => { +export const patchLetter: APIGatewayProxyHandler = async (event) => { let correlationId; diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 5b6d4c6f..203102f7 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -1,3 +1,3 @@ // Export all handlers for ease of access export { getLetters } from './handlers/get-letters'; -export { patchLetters } from './handlers/patch-letters'; +export { patchLetter } from './handlers/patch-letter'; diff --git a/postman/Sandbox.postman_collection.json b/postman/Sandbox.postman_collection.json index ed0e6e3f..b4aabdb2 100644 --- a/postman/Sandbox.postman_collection.json +++ b/postman/Sandbox.postman_collection.json @@ -641,7 +641,7 @@ } } ], - "name": "200 - PatchLetters", + "name": "200 - PatchLetter", "request": { "body": { "mode": "raw", @@ -745,7 +745,7 @@ "body": null, "cookie": [], "header": null, - "name": "200 - PatchLetters-CANCELLED", + "name": "200 - PatchLetter-CANCELLED", "originalRequest": { "body": { "mode": "raw", diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml index 00ebe84d..923e0976 100644 --- a/sandbox/api/openapi.yaml +++ b/sandbox/api/openapi.yaml @@ -609,7 +609,7 @@ paths: patch: description: Update the status of a letter by providing the new status in the request body. - operationId: patchLetters + operationId: patchLetter parameters: - description: "Unique request identifier, in the format of a GUID" explode: false @@ -755,7 +755,7 @@ paths: id: "123654789" type: Letter schema: - $ref: "#/components/schemas/patchLetters_request" + $ref: "#/components/schemas/patchLetter_request" required: true responses: "200": @@ -1183,7 +1183,7 @@ components: - specificationId - status type: object - patchLetters_request: + patchLetter_request: properties: data: $ref: "#/components/schemas/postLetters_request_data_inner" diff --git a/sandbox/controllers/LetterController.js b/sandbox/controllers/LetterController.js index fdf99d8a..d1954fac 100644 --- a/sandbox/controllers/LetterController.js +++ b/sandbox/controllers/LetterController.js @@ -16,8 +16,8 @@ const listLetters = async (request, response) => { await Controller.handleRequest(request, response, service.listLetters); }; -const patchLetters = async (request, response) => { - await Controller.handleRequest(request, response, service.patchLetters); +const patchLetter = async (request, response) => { + await Controller.handleRequest(request, response, service.patchLetter); }; const postLetters = async (request, response) => { @@ -28,6 +28,6 @@ const postLetters = async (request, response) => { module.exports = { getLetterStatus, listLetters, - patchLetters, + patchLetter, postLetters, }; diff --git a/sandbox/services/LetterService.js b/sandbox/services/LetterService.js index f964f5c7..92e8f9ee 100644 --- a/sandbox/services/LetterService.js +++ b/sandbox/services/LetterService.js @@ -74,14 +74,14 @@ const listLetters = ({ xRequestId, xCorrelationId, limit = 10 }) => new Promise( * * xRequestId String Unique request identifier, in the format of a GUID * id String Unique identifier of this resource -* patchLettersRequest PatchLettersRequest +* patchLetterRequest PatchLetterRequest * xCorrelationId String An optional ID which you can use to track transactions across multiple systems. It can take any value, but we recommend avoiding `.` characters. If not provided in the request, NHS Notify will default to a system generated ID in its place. The ID will be returned in a response header. (optional) * returns getLetterStatus_200_response * */ -const patchLetters = ({ xRequestId, id, body, xCorrelationId }) => new Promise( +const patchLetter = ({ xRequestId, id, body, xCorrelationId }) => new Promise( async (resolve, reject) => { try { - const responseData = await ResponseProvider.patchLettersResponse(body); + const responseData = await ResponseProvider.patchLetterResponse(body); const content = await fs.readFile(responseData.responsePath); const fileData = JSON.parse(content); @@ -131,6 +131,6 @@ const postLetters = ({ xRequestId, body, xCorrelationId }) => new Promise( module.exports = { getLetterStatus, listLetters, - patchLetters, + patchLetter, postLetters, }; diff --git a/sandbox/utils/ResponseProvider.js b/sandbox/utils/ResponseProvider.js index 48fcaf9c..746f6607 100644 --- a/sandbox/utils/ResponseProvider.js +++ b/sandbox/utils/ResponseProvider.js @@ -74,8 +74,8 @@ async function getLettersResponse(limit) { return mapExampleGetResponse(status, getLettersfileMap); } -async function patchLettersResponse(request) { - const patchLettersFileMap = { +async function patchLetterResponse(request) { + const patchLetterFileMap = { 'data/examples/patchLetter/requests/patchLetter_DEFAULT.json': {responsePath: 'data/examples/patchLetter/responses/patchLetter_PENDING.json', responseCode: 200}, 'data/examples/patchLetter/requests/patchLetter_PENDING.json': {responsePath:'data/examples/patchLetter/responses/patchLetter_PENDING.json',responseCode: 200}, 'data/examples/patchLetter/requests/patchLetter_ACCEPTED.json': {responsePath:'data/examples/patchLetter/responses/patchLetter_ACCEPTED.json',responseCode: 200}, @@ -90,14 +90,14 @@ async function patchLettersResponse(request) { 'data/examples/patchLetter/requests/patchLetter_INVALID.json': {responsePath:'data/examples/errors/responses/badRequest.json',responseCode: 400}, 'data/examples/patchLetter/requests/patchLetter_NOTFOUND.json': {responsePath:'data/examples/errors/responses/resourceNotFound.json',responseCode: 404}, }; - return await mapExampleResponse(request, patchLettersFileMap); + return await mapExampleResponse(request, patchLetterFileMap); } async function postLettersResponse(request) { - const patchLettersFileMap = { + const patchLetterFileMap = { 'data/examples/postLetter/requests/postLetters.json': {responsePath: 'data/examples/postLetter/responses/postLetters.json', responseCode: 200}, }; - return await mapExampleResponse(request, patchLettersFileMap); + return await mapExampleResponse(request, patchLetterFileMap); } async function postMIResponse(request) { @@ -120,7 +120,7 @@ async function getLetterDataResponse(id) { module.exports = { getLetterStatusResponse, getLettersResponse, - patchLettersResponse, + patchLetterResponse, postMIResponse, getLetterDataResponse, postLettersResponse diff --git a/specification/api/components/endpoints/patchLetter.yml b/specification/api/components/endpoints/patchLetter.yml index 6deaca9d..411d1b64 100644 --- a/specification/api/components/endpoints/patchLetter.yml +++ b/specification/api/components/endpoints/patchLetter.yml @@ -1,6 +1,6 @@ summary: Update the status of a letter description: Update the status of a letter by providing the new status in the request body. -operationId: patchLetters +operationId: patchLetter requestBody: $ref: "../requests/patchLetterRequest.yml" responses: From e2ae5fbd9b3b559017f62d9a936ecc435d73e10f Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Fri, 3 Oct 2025 09:56:52 +0000 Subject: [PATCH 07/31] Fix sonar issues --- lambdas/api-handler/src/handlers/patch-letter.ts | 2 +- lambdas/api-handler/src/mappers/letter-mapper.ts | 2 +- lambdas/api-handler/src/services/letter-operations.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index fa7babfc..b719cf0f 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -32,7 +32,7 @@ export const patchLetter: APIGatewayProxyHandler = async (event) => { else throw error; } - const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId!), letterId!, letterRepo); + const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, letterRepo); return { statusCode: 200, diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index 14948842..e35bcb00 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,5 +1,5 @@ import { LetterBase, LetterStatus } from "../../../../internal/datastore"; -import { GetLettersResponse, GetLettersResponseSchema, LetterDto, LetterStatusSchema, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters'; +import { GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters'; export function mapToLetterDto(request: PatchLetterRequest, supplierId: string) : LetterDto { return { diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index ed66d252..4344ef95 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -1,4 +1,4 @@ -import { Letter, LetterBase, LetterRepository } from '../../../../internal/datastore/src' +import { LetterBase, LetterRepository } from '../../../../internal/datastore/src' import { NotFoundError, ValidationError } from '../errors'; import { LetterDto, PatchLetterResponse } from '../contracts/letters'; import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; From 520be4526d96f176a87298652c12d616d879743b Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 6 Oct 2025 17:01:34 +0000 Subject: [PATCH 08/31] Read headers in lower case --- lambdas/api-handler/src/handlers/get-letters.ts | 7 ++++--- lambdas/api-handler/src/handlers/patch-letter.ts | 7 ++++--- .../src/utils/__tests__/validation.test.ts | 15 ++++++++++++++- lambdas/api-handler/src/utils/validation.ts | 4 ++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 5a60f217..1f6f7df9 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; import { getLettersForSupplier } from "../services/letter-operations"; import { createLetterRepository } from "../infrastructure/letter-repo-factory"; -import { assertNotEmpty } from "../utils/validation"; +import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { lambdaConfig } from "../config/lambda-config"; import pino from 'pino'; @@ -27,8 +27,9 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { try { assertNotEmpty(event.headers, new Error("The request headers are empty")); - correlationId = assertNotEmpty(event.headers[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(event.headers[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit); const letters = await getLettersForSupplier( diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index b719cf0f..c381d5bf 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -6,7 +6,7 @@ import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; import { lambdaConfig } from "../config/lambda-config"; -import { assertNotEmpty } from '../utils/validation'; +import { assertNotEmpty, lowerCaseKeys } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; const letterRepo = createLetterRepository(); @@ -16,8 +16,9 @@ export const patchLetter: APIGatewayProxyHandler = async (event) => { try { assertNotEmpty(event.headers, new Error('The request headers are empty')); - correlationId = assertNotEmpty(event.headers[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(event.headers[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); diff --git a/lambdas/api-handler/src/utils/__tests__/validation.test.ts b/lambdas/api-handler/src/utils/__tests__/validation.test.ts index c35bc332..8725cabb 100644 --- a/lambdas/api-handler/src/utils/__tests__/validation.test.ts +++ b/lambdas/api-handler/src/utils/__tests__/validation.test.ts @@ -1,4 +1,4 @@ -import { assertNotEmpty } from "../validation"; +import { assertNotEmpty, lowerCaseKeys } from "../validation"; describe("assertNotEmpty", () => { const error = new Error(); @@ -52,3 +52,16 @@ describe("assertNotEmpty", () => { expect(result).toBe(arr); }); }); + +describe("lowerCaseKeys", () => { + it("lowers case on header keys", () => { + const headers: Record = {'Aa_Bb-Cc':1, 'b':2}; + const result = lowerCaseKeys(headers); + expect(result).toEqual({'aa_bb-cc':1, 'b':2}); + }); + + it("handles empty input", () => { + const result = lowerCaseKeys({}); + expect(result).toEqual({}); + }); +}); diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index f29b7ae0..4bb50960 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -16,3 +16,7 @@ export function assertNotEmpty( return value; } + +export function lowerCaseKeys(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); +} From 813c409d5d339f931406cc72a2619b553357763e Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 6 Oct 2025 17:50:15 +0000 Subject: [PATCH 09/31] wip --- .../src/handlers/get-letter-data.ts | 41 +++++++++++++++++++ .../src/services/letter-operations.ts | 16 ++++++++ 2 files changed, 57 insertions(+) create mode 100644 lambdas/api-handler/src/handlers/get-letter-data.ts diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts new file mode 100644 index 00000000..60453b0a --- /dev/null +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -0,0 +1,41 @@ +import { APIGatewayProxyHandler } from "aws-lambda"; +import { createLetterRepository } from "../infrastructure/letter-repo-factory"; +import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; +import { ApiErrorDetail } from '../contracts/errors'; +import { lambdaConfig } from "../config/lambda-config"; +import pino from 'pino'; +import { mapErrorToResponse } from "../mappers/error-mapper"; +import { ValidationError } from "../errors"; + +const letterRepo = createLetterRepository(); + +const log = pino(); + +// The endpoint should only return pending letters for now +const status = "PENDING"; + +export const getLetters: APIGatewayProxyHandler = async (event) => { + + let correlationId; + + try { + assertNotEmpty(event.headers, new Error("The request headers are empty")); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + + // assert if letter exists and retrieve + // call service + + + // map response + + return { + statusCode: 200, + body: JSON.stringify({}, null, 2), + }; + } + catch (error) { + return mapErrorToResponse(error, correlationId); + } +}; diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 4344ef95..02332cb7 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -29,3 +29,19 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str return mapToPatchLetterResponse(updatedLetter); } + +export const getLetterData = async (supplierId: string, letterId: string, letterRepo: LetterRepository): Promise => { + + let letter; + + try { + letter = await letterRepo.getLetterById(supplierId, letterId); + } catch (error) { + if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { + throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); + } + throw error; + } + + +} From 0d5b6d66f87b5e087ea4e4dc0fbd9ef5947cb5c9 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Wed, 8 Oct 2025 10:29:40 +0000 Subject: [PATCH 10/31] wip --- .../terraform/components/api/README.md | 1 + .../terraform/components/api/locals.tf | 1 + .../api/module_lambda_get_letter_data.tf | 72 +++ .../components/api/resources/spec.tmpl.json | 28 + lambdas/api-handler/package.json | 1 + .../src/handlers/get-letter-data.ts | 21 +- .../src/services/letter-operations.ts | 19 +- package-lock.json | 477 ++++++++++++------ 8 files changed, 456 insertions(+), 164 deletions(-) create mode 100644 infrastructure/terraform/components/api/module_lambda_get_letter_data.tf diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index fd16d4e6..6487f904 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -34,6 +34,7 @@ No requirements. |------|--------|---------| | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | +| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 982bc110..b6d04acd 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -9,6 +9,7 @@ locals { AWS_REGION = var.region AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn + GET_LETTER_DATA_LAMBDA_ARN = module.get_letters.function_arn PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn }) diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf new file mode 100644 index 00000000..6b666e15 --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -0,0 +1,72 @@ +module "get_letter_data" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" + + function_name = "get_letter_data" + description = "Get the letter data" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.get_letter_data_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "api-handler/dist" + function_include_common = true + handler_function_name = "getLetterData" + runtime = "nodejs22.x" + memory = 128 + timeout = 5 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + send_to_firehose = true + log_destination_arn = local.destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = merge(local.common_lambda_env_vars, {}) +} + +data "aws_iam_policy_document" "get_letter_data_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, ## Requires shared kms module + ] + } + + statement { + sid = "AllowDynamoDBAccess" + effect = "Allow" + + actions = [ + "dynamodb:BatchGetItem", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + ] + + resources = [ + aws_dynamodb_table.letters.arn, + "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" + ] + } +} diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index ec58b67e..d02beb98 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -54,6 +54,34 @@ } }, "/letters/{id}": { + "get": { + "description": "Returns 302 with pre-signed URL to the letter data", + "responses": { + "302": { + "description": "Found" + } + }, + "security": [ + { + "LambdaAuthorizer": [] + } + ], + "summary": "Get letter data", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_DATA_LAMBDA_ARN}/invocations" + } + }, "parameters": [ { "description": "Unique identifier of this resource", diff --git a/lambdas/api-handler/package.json b/lambdas/api-handler/package.json index fd665012..84601bbd 100644 --- a/lambdas/api-handler/package.json +++ b/lambdas/api-handler/package.json @@ -3,6 +3,7 @@ "esbuild": "^0.24.0" }, "devDependencies": { + "@aws-sdk/s3-request-presigner": "^3.901.0", "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.148", "@types/jest": "^29.5.14", diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 60453b0a..17a10dfe 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -3,18 +3,13 @@ import { createLetterRepository } from "../infrastructure/letter-repo-factory"; import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { lambdaConfig } from "../config/lambda-config"; -import pino from 'pino'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; +import { getLetterDataUrl } from "../services/letter-operations"; const letterRepo = createLetterRepository(); -const log = pino(); - -// The endpoint should only return pending letters for now -const status = "PENDING"; - -export const getLetters: APIGatewayProxyHandler = async (event) => { +export const getLetterData: APIGatewayProxyHandler = async (event) => { let correlationId; @@ -23,16 +18,12 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { const lowerCasedHeaders = lowerCaseKeys(event.headers); correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - - // assert if letter exists and retrieve - // call service - - - // map response + const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); return { - statusCode: 200, - body: JSON.stringify({}, null, 2), + statusCode: 302, + Location: getLetterDataUrl(supplierId, letterId, letterRepo), + body: '' }; } catch (error) { diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 02332cb7..5df5a3e7 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -3,6 +3,8 @@ import { NotFoundError, ValidationError } from '../errors'; import { LetterDto, PatchLetterResponse } from '../contracts/letters'; import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; import { ApiErrorDetail } from '../contracts/errors'; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; export const getLettersForSupplier = async (supplierId: string, status: string, limit: number, letterRepo: LetterRepository): Promise => { @@ -30,7 +32,7 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str return mapToPatchLetterResponse(updatedLetter); } -export const getLetterData = async (supplierId: string, letterId: string, letterRepo: LetterRepository): Promise => { +export const getLetterDataUrl = async (supplierId: string, letterId: string, letterRepo: LetterRepository): Promise => { let letter; @@ -43,5 +45,20 @@ export const getLetterData = async (supplierId: string, letterId: string, letter throw error; } + return getPresignedUrl(letter.url); +} + +async function getPresignedUrl(s3Uri: string) { + const client = new S3Client(); + + const url = new URL(s3Uri); // works for s3:// URIs + const bucket = url.hostname; + const key = url.pathname.slice(1); // remove leading '/' + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + }); + return await getSignedUrl(client, command, { expiresIn: 3600 }); } diff --git a/package-lock.json b/package-lock.json index 4c9878f5..224a5f2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,7 @@ "esbuild": "^0.24.0" }, "devDependencies": { + "@aws-sdk/s3-request-presigner": "^3.901.0", "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.148", "@types/jest": "^29.5.14", @@ -1643,6 +1644,156 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.901.0.tgz", + "integrity": "sha512-G/0G5tL3beETs2zkI0YQuM2SkrAsYJSe2vN2XtouVSN5c9v6EiSvdSsHAqMhLebnSs2suUkq0JO9ZotbXkUfMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-format-url": "3.901.0", + "@smithy/middleware-endpoint": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/core": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.901.0.tgz", + "integrity": "sha512-brKAc3y64tdhyuEf+OPIUln86bRTqkLgb9xkd6kUdIeA5+qmp/N6amItQz+RN4k4O3kqkCPYnAd3LonTKluobw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.901.0", + "@aws-sdk/xml-builder": "3.901.0", + "@smithy/core": "^3.14.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.901.0.tgz", + "integrity": "sha512-prgjVC3fDT2VIlmQPiw/cLee8r4frTam9GILRUVQyDdNtshNwV3MiaSCLzzQJjKJlLgnBLNUHJCSmvUVtg+3iA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-arn-parser": "3.893.0", + "@smithy/core": "^3.14.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.4.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.901.0.tgz", + "integrity": "sha512-2IWxbll/pRucp1WQkHi2W5E2SVPGBvk4Is923H7gpNksbVFws18ItjMM8ZpGm44cJEoy1zR5gjhLFklatpuoOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/signature-v4": "^5.3.0", + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/types": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/xml-builder": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.901.0.tgz", + "integrity": "sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.6.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.896.0", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.896.0.tgz", @@ -1739,6 +1890,36 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.901.0.tgz", + "integrity": "sha512-GGUnJKrh3OF1F3YRSWtwPLbN904Fcfxf03gujyq1rcrDRPEkzoZB+2BzNkB27SsU6lAlwNq+4aRlZRVUloPiag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.901.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url/node_modules/@aws-sdk/types": { + "version": "3.901.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.901.0.tgz", + "integrity": "sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.804.0", "license": "Apache-2.0", @@ -4269,12 +4450,12 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", - "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.0.tgz", + "integrity": "sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4323,20 +4504,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.12.0.tgz", - "integrity": "sha512-zJeAgogZfbwlPGL93y4Z/XNeIN37YCreRUd6YMIRvaq+6RnBK8PPYYIQ85Is/GglPh3kNImD5riDCXbVSDpCiQ==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.14.0.tgz", + "integrity": "sha512-XJ4z5FxvY/t0Dibms/+gLJrI5niRoY0BCmE02fwmPcRYFPI4KI876xaE79YGWIKnEslMbuQPsIEsoU/DXa0DoA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-body-length-browser": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-stream": "^4.3.2", - "@smithy/util-utf8": "^4.1.0", - "@smithy/uuid": "^1.0.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-stream": "^4.4.0", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, "engines": { @@ -4430,15 +4611,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", - "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.0.tgz", + "integrity": "sha512-BG3KSmsx9A//KyIfw+sqNmWFr1YBUr+TwpxFT7yPqAk0yyDh7oSNgzfNH7pS6OC099EGx2ltOULvumCFe8bcgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4503,9 +4684,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", - "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4543,18 +4724,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.4.tgz", - "integrity": "sha512-FZ4hzupOmthm8Q8ujYrd0I+/MHwVMuSTdkDtIQE0xVuvJt9pLT6Q+b0p4/t+slDyrpcf+Wj7SN+ZqT5OryaaZg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.0.tgz", + "integrity": "sha512-jFVjuQeV8TkxaRlcCNg0GFVgg98tscsmIrIwRFeC74TIUyLE3jmY9xgc1WXrPQYRjQNK3aRoaIk6fhFRGOIoGw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.12.0", - "@smithy/middleware-serde": "^4.1.1", - "@smithy/node-config-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", - "@smithy/url-parser": "^4.1.1", - "@smithy/util-middleware": "^4.1.1", + "@smithy/core": "^3.14.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4582,13 +4763,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", - "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.0.tgz", + "integrity": "sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4596,12 +4777,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", - "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.0.tgz", + "integrity": "sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4609,14 +4790,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", - "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.0.tgz", + "integrity": "sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.1.1", - "@smithy/shared-ini-file-loader": "^4.2.0", - "@smithy/types": "^4.5.0", + "@smithy/property-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.3.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4624,15 +4805,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", - "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.3.0.tgz", + "integrity": "sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/querystring-builder": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/abort-controller": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/querystring-builder": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4640,12 +4821,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", - "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.0.tgz", + "integrity": "sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4653,12 +4834,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", - "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.0.tgz", + "integrity": "sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4666,13 +4847,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", - "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.0.tgz", + "integrity": "sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-uri-escape": "^4.1.0", + "@smithy/types": "^4.6.0", + "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4680,12 +4861,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", - "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.0.tgz", + "integrity": "sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4705,12 +4886,12 @@ } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", - "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.0.tgz", + "integrity": "sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4718,18 +4899,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", - "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.0.tgz", + "integrity": "sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-middleware": "^4.1.1", - "@smithy/util-uri-escape": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4737,17 +4918,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.4.tgz", - "integrity": "sha512-qL7O3VDyfzCSN9r+sdbQXGhaHtrfSJL30En6Jboj0I3bobf2g1/T0eP2L4qxqrEW26gWhJ4THI4ElVVLjYyBHg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.7.0.tgz", + "integrity": "sha512-3BDx/aCCPf+kkinYf5QQhdQ9UAGihgOVqI3QO5xQfSaIWvUE4KYLtiGRWsNe1SR7ijXC0QEPqofVp5Sb0zC8xQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.12.0", - "@smithy/middleware-endpoint": "^4.2.4", - "@smithy/middleware-stack": "^4.1.1", - "@smithy/protocol-http": "^5.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-stream": "^4.3.2", + "@smithy/core": "^3.14.0", + "@smithy/middleware-endpoint": "^4.3.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-stream": "^4.4.0", "tslib": "^2.6.2" }, "engines": { @@ -4755,9 +4936,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", - "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.6.0.tgz", + "integrity": "sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4767,13 +4948,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", - "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.0.tgz", + "integrity": "sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.1.1", - "@smithy/types": "^4.5.0", + "@smithy/querystring-parser": "^4.2.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4781,13 +4962,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", - "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.2.0.tgz", + "integrity": "sha512-+erInz8WDv5KPe7xCsJCp+1WCjSbah9gWcmUXc9NqmhyPx59tf7jqFz+za1tRG1Y5KM1Cy1rWCcGypylFp4mvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4795,9 +4976,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", - "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4819,12 +5000,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", - "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.1.0", + "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4832,9 +5013,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", - "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4892,9 +5073,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", - "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4904,12 +5085,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", - "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.0.tgz", + "integrity": "sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", + "@smithy/types": "^4.6.0", "tslib": "^2.6.2" }, "engines": { @@ -4931,18 +5112,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", - "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.4.0.tgz", + "integrity": "sha512-vtO7ktbixEcrVzMRmpQDnw/Ehr9UWjBvSJ9fyAbadKkC4w5Cm/4lMO8cHz8Ysb8uflvQUNRcuux/oNHKPXkffg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.2.1", - "@smithy/node-http-handler": "^4.2.1", - "@smithy/types": "^4.5.0", - "@smithy/util-base64": "^4.1.0", - "@smithy/util-buffer-from": "^4.1.0", - "@smithy/util-hex-encoding": "^4.1.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/fetch-http-handler": "^5.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/types": "^4.6.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4950,9 +5131,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", - "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4962,12 +5143,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", - "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -4989,9 +5170,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.0.0.tgz", - "integrity": "sha512-OlA/yZHh0ekYFnbUkmYBDQPE6fGfdrvgz39ktp8Xf+FA6BfxLejPTMDOG0Nfk5/rDySAz1dRbFf24zaAFYVXlQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" From 933524ffb26b7a3edf24ee53906f7f5f8f9587d7 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Wed, 8 Oct 2025 15:16:29 +0000 Subject: [PATCH 11/31] Add tf and tests --- .../api/module_lambda_get_letter_data.tf | 12 +++ .../__tests__/get-letter-data.test.ts | 97 +++++++++++++++++++ .../src/handlers/get-letter-data.ts | 2 +- .../__tests__/letter-operations.test.ts | 61 +++++++++++- .../src/services/letter-operations.ts | 7 +- 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf index 6b666e15..929f3e77 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -69,4 +69,16 @@ data "aws_iam_policy_document" "get_letter_data_lambda" { "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" ] } + + statement { + sid = "S3GetObjectForPresign" + actions = ["s3:GetObject"] + resources = ["${module.s3bucket_test_letters.arn}/*"] + } + + statement { + sid = "KmsForS3Objects" + actions = ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey"] + resources = [module.s3bucket_test_letters.kms_key_arn] + } } diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts new file mode 100644 index 00000000..ca54db47 --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -0,0 +1,97 @@ +import type { APIGatewayProxyResult, Context } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { makeApiGwEvent } from './utils/test-utils'; +import * as letterService from '../../services/letter-operations'; +import { mapErrorToResponse } from '../../mappers/error-mapper'; +import { ValidationError } from '../../errors'; +import * as errors from '../../contracts/errors'; +import { getLetterData } from '../get-letter-data'; + +jest.mock('../../mappers/error-mapper'); +const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); +const expectedErrorResponse: APIGatewayProxyResult = { + statusCode: 400, + body: 'Error' +}; +mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); + +jest.mock('../../services/letter-operations'); + +jest.mock('../../config/lambda-config', () => ({ + lambdaConfig: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id' + } +})); + +describe('API Lambda handler', () => { + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('returns 302 Found with a pre signed url', async () => { + + const mockedGetLetterDataUrlService = letterService.getLetterDataUrl as jest.Mock; + mockedGetLetterDataUrlService.mockResolvedValue('https://somePreSignedUrl.com'); + + const event = makeApiGwEvent({path: '/letters/letter1/data', + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const result = await getLetterData(event, context, callback); + + expect(result).toEqual({ + statusCode: 302, + Location: 'https://somePreSignedUrl.com', + body: '' + }); + }); + + it('returns 400 for missing supplier ID (empty headers)', async () => { + const event = makeApiGwEvent({ path: '/letters/letter1/data', headers: {}, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const result = await getLetterData(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns 500 if correlation id not provided in request', async () => { + const event = makeApiGwEvent({ + path: '/letters/letter1/data', + queryStringParameters: { limit: '2000' }, + headers: {'nhsd-supplier-id': 'supplier1'}, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const result = await getLetterData(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error response when path parameter letterId is not found', async () => { + const event = makeApiGwEvent({ + path: '/letters/', + headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const result = await getLetterData(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); + expect(result).toEqual(expectedErrorResponse); + }); +}); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 17a10dfe..fce4d6fc 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -22,7 +22,7 @@ export const getLetterData: APIGatewayProxyHandler = async (event) => { return { statusCode: 302, - Location: getLetterDataUrl(supplierId, letterId, letterRepo), + Location: await getLetterDataUrl(supplierId, letterId, letterRepo), body: '' }; } diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 7420c311..c8538280 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,7 +1,22 @@ import { Letter } from '../../../../../internal/datastore/src'; -import { LetterDto, LetterStatus } from '../../contracts/letters'; -import { getLettersForSupplier, patchLetterStatus } from '../letter-operations'; +import { LetterDto } from '../../contracts/letters'; +import { getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations'; +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(), +})); + +jest.mock('@aws-sdk/client-s3', () => { + return { + S3Client: jest.fn().mockImplementation(() => ({})), + GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + }; +}); +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; + +const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction; +const MockedGetObjectCommand = GetObjectCommand as unknown as jest.Mock; function makeLetter(id: string, status: Letter['status']) : Letter { return { @@ -10,7 +25,7 @@ function makeLetter(id: string, status: Letter['status']) : Letter { supplierId: 'supplier1', specificationId: 'spec123', groupId: 'group123', - url: 'https://example.com/letter/abc123', + url: `s3://letterDataBucket/${id}.pdf`, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), supplierStatus: `supplier1#${status}`, @@ -104,3 +119,43 @@ describe('patchLetterStatus function', () => { await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); }); }); + +describe('getLetterDataUrl function', () => { + + const updatedLetter = makeLetter("letter1", "REJECTED"); + + it('should return pre signed url successfully', async () => { + const mockRepo = { + getLetterById: jest.fn().mockResolvedValue(updatedLetter) + }; + + mockedGetSignedUrl.mockResolvedValue('http://somePreSignedUrl.com'); + + const result = await getLetterDataUrl('supplier1', 'letter1', mockRepo as any); + + expect(mockedGetSignedUrl).toHaveBeenCalled(); + expect(MockedGetObjectCommand).toHaveBeenCalledWith({ + Bucket: 'letterDataBucket', + Key: 'letter1.pdf' + }); + + expect(result).toEqual('http://somePreSignedUrl.com'); + }); + + it('should throw notFoundError when letter does not exist', async () => { + const mockRepo = { + getLetterById: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1')) + }; + + await expect(getLetterDataUrl('supplier1', 'letter42', mockRepo as any)).rejects.toThrow("No resource found with that ID"); + }); + + it('should throw unexpected error', async () => { + + const mockRepo = { + getLetterById: jest.fn().mockRejectedValue(new Error('unexpected error')) + }; + + await expect(getLetterDataUrl('supplier1', 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); + }); +}); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 5df5a3e7..9763c059 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -3,8 +3,8 @@ import { NotFoundError, ValidationError } from '../errors'; import { LetterDto, PatchLetterResponse } from '../contracts/letters'; import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; import { ApiErrorDetail } from '../contracts/errors'; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; export const getLettersForSupplier = async (supplierId: string, status: string, limit: number, letterRepo: LetterRepository): Promise => { @@ -38,14 +38,13 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, let try { letter = await letterRepo.getLetterById(supplierId, letterId); + return await getPresignedUrl(letter.url); } catch (error) { if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); } throw error; } - - return getPresignedUrl(letter.url); } async function getPresignedUrl(s3Uri: string) { From 40f75807caba1208609d0e679f61cd3ed71cf444 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Wed, 8 Oct 2025 16:33:01 +0000 Subject: [PATCH 12/31] Fix tf --- .../components/api/module_lambda_get_letter_data.tf | 6 ------ 1 file changed, 6 deletions(-) diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf index 929f3e77..329a1025 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -75,10 +75,4 @@ data "aws_iam_policy_document" "get_letter_data_lambda" { actions = ["s3:GetObject"] resources = ["${module.s3bucket_test_letters.arn}/*"] } - - statement { - sid = "KmsForS3Objects" - actions = ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey"] - resources = [module.s3bucket_test_letters.kms_key_arn] - } } From a38c8f8d460f2a9a899dc74d1a2298c61b7d0b16 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 07:49:42 +0000 Subject: [PATCH 13/31] Fix spec and return code --- .../components/api/resources/spec.tmpl.json | 72 +++++++++++-------- .../__tests__/get-letter-data.test.ts | 4 +- .../src/handlers/get-letter-data.ts | 2 +- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index d02beb98..663c760b 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -54,34 +54,6 @@ } }, "/letters/{id}": { - "get": { - "description": "Returns 302 with pre-signed URL to the letter data", - "responses": { - "302": { - "description": "Found" - } - }, - "security": [ - { - "LambdaAuthorizer": [] - } - ], - "summary": "Get letter data", - "x-amazon-apigateway-integration": { - "contentHandling": "CONVERT_TO_TEXT", - "credentials": "${APIG_EXECUTION_ROLE_ARN}", - "httpMethod": "POST", - "passthroughBehavior": "WHEN_NO_TEMPLATES", - "responses": { - ".*": { - "statusCode": "200" - } - }, - "timeoutInMillis": 29000, - "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_DATA_LAMBDA_ARN}/invocations" - } - }, "parameters": [ { "description": "Unique identifier of this resource", @@ -133,6 +105,50 @@ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations" } } + }, + "/letters/{id}/data": { + "get": { + "operationId": "getDataId", + "responses": { + "303": { + "description": "See Other", + "headers": { + "Location": { + "description": "The signed S3 URL of the data file to download", + "example": "https://examples3bucket.com/filelocation", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Resource not found" + }, + "429": { + "description": "Too many requests" + }, + "500": { + "description": "Server error" + } + }, + "summary": "Fetch a data file", + "tags": [ + "data" + ] + }, + "parameters": [ + { + "description": "Unique identifier of this resource", + "in": "path", + "name": "id", + "required": true, + "schema": { + "example": "24L5eYSWGzCHlGmzNxuqVusPxDg", + "type": "string" + } + } + ] } } } diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index ca54db47..8186e212 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -31,7 +31,7 @@ describe('API Lambda handler', () => { jest.resetModules(); }); - it('returns 302 Found with a pre signed url', async () => { + it('returns 303 Found with a pre signed url', async () => { const mockedGetLetterDataUrlService = letterService.getLetterDataUrl as jest.Mock; mockedGetLetterDataUrlService.mockResolvedValue('https://somePreSignedUrl.com'); @@ -46,7 +46,7 @@ describe('API Lambda handler', () => { const result = await getLetterData(event, context, callback); expect(result).toEqual({ - statusCode: 302, + statusCode: 303, Location: 'https://somePreSignedUrl.com', body: '' }); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index fce4d6fc..e6720d20 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -21,7 +21,7 @@ export const getLetterData: APIGatewayProxyHandler = async (event) => { const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); return { - statusCode: 302, + statusCode: 303, Location: await getLetterDataUrl(supplierId, letterId, letterRepo), body: '' }; From d670dbbe4bead196b1e64417c6aff86dc7096eb1 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 08:51:55 +0000 Subject: [PATCH 14/31] Fix spec --- .../components/api/resources/spec.tmpl.json | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 663c760b..e89719d4 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -132,10 +132,26 @@ "description": "Server error" } }, + "security": [ + { + "LambdaAuthorizer": [] + } + ], "summary": "Fetch a data file", - "tags": [ - "data" - ] + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations" + } }, "parameters": [ { From b879ed4aaf237185d63962539269b7816867511a Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 11:21:06 +0000 Subject: [PATCH 15/31] Fix spec --- .../terraform/components/api/resources/spec.tmpl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index e89719d4..a2284fe9 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -150,7 +150,7 @@ }, "timeoutInMillis": 29000, "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations" + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_DATA_LAMBDA_ARN}/invocations" } }, "parameters": [ From f4ed8d29d9a356c487f4089c72838c552dcc0505 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 11:33:30 +0000 Subject: [PATCH 16/31] Fix tf --- .../components/api/iam_role_api_gateway_execution_role.tf | 3 ++- infrastructure/terraform/components/api/locals.tf | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf index 4ceca3cd..7faefb31 100644 --- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf @@ -50,7 +50,8 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { resources = [ module.authorizer_lambda.function_arn, module.get_letters.function_arn, - module.patch_letter.function_arn + module.patch_letter.function_arn, + module.get_letter_data.function_arn ] } } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index b6d04acd..a46a2cfc 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -9,7 +9,7 @@ locals { AWS_REGION = var.region AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn - GET_LETTER_DATA_LAMBDA_ARN = module.get_letters.function_arn + GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn }) From f2cf557dd33365d7813d0a0f9dec1141e874b662 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 11:45:21 +0000 Subject: [PATCH 17/31] Fix export --- lambdas/api-handler/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 203102f7..c32ac6d9 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -1,3 +1,4 @@ // Export all handlers for ease of access export { getLetters } from './handlers/get-letters'; +export { getLetterData } from './handlers/get-letter-data'; export { patchLetter } from './handlers/patch-letter'; From 95bc137d749db3f2ea1c68c45e5e3018e09855b4 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 12:14:12 +0000 Subject: [PATCH 18/31] Fix location header --- .../src/handlers/__tests__/get-letter-data.test.ts | 4 +++- lambdas/api-handler/src/handlers/get-letter-data.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index 8186e212..a1443604 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -47,7 +47,9 @@ describe('API Lambda handler', () => { expect(result).toEqual({ statusCode: 303, - Location: 'https://somePreSignedUrl.com', + headers: { + 'Location': 'https://somePreSignedUrl.com', + }, body: '' }); }); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index e6720d20..7a6a5adf 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -22,7 +22,9 @@ export const getLetterData: APIGatewayProxyHandler = async (event) => { return { statusCode: 303, - Location: await getLetterDataUrl(supplierId, letterId, letterRepo), + headers: { + 'Location': await getLetterDataUrl(supplierId, letterId, letterRepo) + }, body: '' }; } From d1e5da381ad6a2922acba3df89c3c66af469bb0b Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 13:36:28 +0000 Subject: [PATCH 19/31] Fix tests and tf --- .../components/api/module_lambda_get_letter_data.tf | 4 +++- lambdas/api-handler/src/__tests__/index.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 lambdas/api-handler/src/__tests__/index.test.ts diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf index 329a1025..05feda01 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -72,7 +72,9 @@ data "aws_iam_policy_document" "get_letter_data_lambda" { statement { sid = "S3GetObjectForPresign" - actions = ["s3:GetObject"] + actions = [ + "s3:GetObject", + "s3:ListBucket"] # allows 404 response instead of 403 if object missing resources = ["${module.s3bucket_test_letters.arn}/*"] } } diff --git a/lambdas/api-handler/src/__tests__/index.test.ts b/lambdas/api-handler/src/__tests__/index.test.ts new file mode 100644 index 00000000..a7106563 --- /dev/null +++ b/lambdas/api-handler/src/__tests__/index.test.ts @@ -0,0 +1,13 @@ +import * as handlers from "../index"; +jest.mock('../config/lambda-config', () => ({ + lambdaConfig: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id' + } +})); + +it("exports", () => { + expect(handlers.getLetters).toBeDefined(); + expect(handlers.patchLetter).toBeDefined(); + expect(handlers.getLetterData).toBeDefined(); +}); From 5bfa8dd365391722690ad58a972096c717cbc68b Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 9 Oct 2025 13:37:00 +0000 Subject: [PATCH 20/31] Add aws cli in devcontainer --- .devcontainer/devcontainer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0104e4dc..fb83b1b6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -63,6 +63,7 @@ "omzPlugins": "https://github.com/zsh-users/zsh-autosuggestions.git https://github.com/zsh-users/zsh-syntax-highlighting.git", "plugins": "zsh-autosuggestions zsh-syntax-highlighting" }, + "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/common-utils": { "configureZshAsDefaultShell": true, "installOhMyZsh": true, @@ -79,7 +80,8 @@ "ghcr.io/devcontainers/features/ruby:1": {} }, "mounts": [ - "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", + "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached" ], "name": "Devcontainer", "postCreateCommand": "scripts/devcontainer/postcreatecommand.sh" From d93c0732c460c4877969f7a57ab88e75a663cbc3 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 13 Oct 2025 11:28:06 +0000 Subject: [PATCH 21/31] Improve dependency injection --- .../api-handler/src/__tests__/index.test.ts | 13 ---- lambdas/api-handler/src/config/deps.ts | 44 +++++++++++ lambdas/api-handler/src/config/env.ts | 21 ++++++ .../api-handler/src/config/lambda-config.ts | 17 ----- .../__tests__/get-letter-data.test.ts | 50 ++++++++----- .../handlers/__tests__/get-letters.test.ts | 67 +++++++++++------ .../handlers/__tests__/patch-letter.test.ts | 69 ++++++++++------- .../src/handlers/get-letter-data.ts | 18 +++-- .../api-handler/src/handlers/get-letters.ts | 30 ++++---- .../api-handler/src/handlers/patch-letter.ts | 19 +++-- .../src/infrastructure/letter-repo-factory.ts | 18 ----- .../mappers/__tests__/error-mapper.test.ts | 9 ++- .../api-handler/src/mappers/error-mapper.ts | 5 +- .../__tests__/letter-operations.test.ts | 75 ++++++++++--------- .../src/services/letter-operations.ts | 12 +-- 15 files changed, 272 insertions(+), 195 deletions(-) delete mode 100644 lambdas/api-handler/src/__tests__/index.test.ts create mode 100644 lambdas/api-handler/src/config/deps.ts create mode 100644 lambdas/api-handler/src/config/env.ts delete mode 100644 lambdas/api-handler/src/config/lambda-config.ts delete mode 100644 lambdas/api-handler/src/infrastructure/letter-repo-factory.ts diff --git a/lambdas/api-handler/src/__tests__/index.test.ts b/lambdas/api-handler/src/__tests__/index.test.ts deleted file mode 100644 index a7106563..00000000 --- a/lambdas/api-handler/src/__tests__/index.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as handlers from "../index"; -jest.mock('../config/lambda-config', () => ({ - lambdaConfig: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id' - } -})); - -it("exports", () => { - expect(handlers.getLetters).toBeDefined(); - expect(handlers.patchLetter).toBeDefined(); - expect(handlers.getLetterData).toBeDefined(); -}); diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts new file mode 100644 index 00000000..e28946df --- /dev/null +++ b/lambdas/api-handler/src/config/deps.ts @@ -0,0 +1,44 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import pino from 'pino'; +import { LetterRepository } from '../../../../internal/datastore'; +import { lambdaEnv, LambdaEnv } from "../config/env"; + +const BASE_TEN = 10; + +let singletonDeps: Deps | null = null; + +export type Deps = { + s3Client: S3Client; + letterRepo: LetterRepository; + logger: pino.Logger, + env: LambdaEnv +}; + +function createLetterRepository(log: pino.Logger, lambdaEnv: LambdaEnv): LetterRepository { + const ddbClient = new DynamoDBClient({}); + const docClient = DynamoDBDocumentClient.from(ddbClient); + const config = { + lettersTableName: lambdaEnv.LETTERS_TABLE_NAME, + ttlHours: parseInt(lambdaEnv.LETTER_TTL_HOURS, BASE_TEN), + }; + + return new LetterRepository(docClient, log, config); +} + +export function getDeps(): Deps { + + if (singletonDeps) return singletonDeps; + + const log = pino(); + + singletonDeps = { + s3Client: new S3Client(), + letterRepo: createLetterRepository(log, lambdaEnv), + logger: log, + env: lambdaEnv + }; + + return singletonDeps; +} diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts new file mode 100644 index 00000000..95d0a038 --- /dev/null +++ b/lambdas/api-handler/src/config/env.ts @@ -0,0 +1,21 @@ +export interface LambdaEnv { + SUPPLIER_ID_HEADER: string; + APIM_CORRELATION_HEADER: string; + LETTERS_TABLE_NAME: string; + LETTER_TTL_HOURS: string; +} + +export const lambdaEnv: LambdaEnv = { + SUPPLIER_ID_HEADER: getEnv('SUPPLIER_ID_HEADER')!, + APIM_CORRELATION_HEADER: getEnv('APIM_CORRELATION_HEADER')!, + LETTERS_TABLE_NAME: getEnv('LETTERS_TABLE_NAME')!, + LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS')! +}; + +function getEnv(name: string, required = true): string | undefined { + const value = process.env[name]; + if (!value && required) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} diff --git a/lambdas/api-handler/src/config/lambda-config.ts b/lambdas/api-handler/src/config/lambda-config.ts deleted file mode 100644 index 7ff98a0f..00000000 --- a/lambdas/api-handler/src/config/lambda-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface LambdaConfig { - SUPPLIER_ID_HEADER: string; - APIM_CORRELATION_HEADER: string; -} - -export const lambdaConfig: LambdaConfig = { - SUPPLIER_ID_HEADER: getEnv("SUPPLIER_ID_HEADER")!, - APIM_CORRELATION_HEADER: getEnv("APIM_CORRELATION_HEADER")! -}; - -function getEnv(name: string, required = true): string | undefined { - const value = process.env[name]; - if (!value && required) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index a1443604..c71a59c3 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -1,13 +1,6 @@ -import type { APIGatewayProxyResult, Context } from 'aws-lambda'; -import { mockDeep } from 'jest-mock-extended'; -import { makeApiGwEvent } from './utils/test-utils'; -import * as letterService from '../../services/letter-operations'; -import { mapErrorToResponse } from '../../mappers/error-mapper'; -import { ValidationError } from '../../errors'; -import * as errors from '../../contracts/errors'; -import { getLetterData } from '../get-letter-data'; - +// mock error mapper jest.mock('../../mappers/error-mapper'); +import { mapErrorToResponse } from '../../mappers/error-mapper'; const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, @@ -15,14 +8,37 @@ const expectedErrorResponse: APIGatewayProxyResult = { }; mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); +// mock letterService jest.mock('../../services/letter-operations'); +import * as letterService from '../../services/letter-operations'; -jest.mock('../../config/lambda-config', () => ({ - lambdaConfig: { +// mock dependencies +jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); +import { Deps, getDeps } from "../../config/deps"; +const mockedGetDeps = getDeps as jest.Mock; +const fakeDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id' - } -})); + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + } as unknown as LambdaEnv +} +mockedGetDeps.mockReturnValue(fakeDeps); + +import type { APIGatewayProxyResult, Context } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { makeApiGwEvent } from './utils/test-utils'; +import { ValidationError } from '../../errors'; +import * as errors from '../../contracts/errors'; +import { getLetterData } from '../get-letter-data'; +import { S3Client } from '@aws-sdk/client-s3'; +import pino from 'pino'; +import { LetterRepository } from '../../../../../internal/datastore/src'; +import { LambdaEnv } from '../../config/env'; describe('API Lambda handler', () => { @@ -63,7 +79,7 @@ describe('API Lambda handler', () => { const result = await getLetterData(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -79,7 +95,7 @@ describe('API Lambda handler', () => { const result = await getLetterData(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -93,7 +109,7 @@ describe('API Lambda handler', () => { const result = await getLetterData(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index a1ef63a1..1e8e0876 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -1,14 +1,23 @@ -import { getLetters } from '../../index'; -import type { APIGatewayProxyResult, Context } from 'aws-lambda'; -import { mockDeep } from 'jest-mock-extended'; -import { makeApiGwEvent } from './utils/test-utils'; -import * as letterService from '../../services/letter-operations'; -import { mapErrorToResponse } from '../../mappers/error-mapper'; -import { getEnvars } from '../get-letters'; -import { ValidationError } from '../../errors'; -import * as errors from '../../contracts/errors'; - +// mock dependencies +jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); +import { Deps, getDeps } from "../../config/deps"; +const mockedGetDeps = getDeps as jest.Mock; +const fakeDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + } as unknown as LambdaEnv +} +mockedGetDeps.mockReturnValue(fakeDeps); + +// mock error mapper jest.mock('../../mappers/error-mapper'); +import { mapErrorToResponse } from '../../mappers/error-mapper'; const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, @@ -16,14 +25,21 @@ const expectedErrorResponse: APIGatewayProxyResult = { }; mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); +//mock letter service jest.mock('../../services/letter-operations'); +import * as letterService from '../../services/letter-operations'; -jest.mock('../../config/lambda-config', () => ({ - lambdaConfig: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id' - } -})); +import type { APIGatewayProxyResult, Context } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { makeApiGwEvent } from './utils/test-utils'; +import { getMaxLimit } from '../get-letters'; +import { ValidationError } from '../../errors'; +import * as errors from '../../contracts/errors'; +import { S3Client } from '@aws-sdk/client-s3'; +import pino from 'pino'; +import { LetterRepository } from '../../../../../internal/datastore/src'; +import { LambdaEnv } from '../../config/env'; +import { getLetters } from "../get-letters"; describe('API Lambda handler', () => { @@ -32,6 +48,7 @@ describe('API Lambda handler', () => { beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); + process.env = { ...originalEnv }; process.env.MAX_LIMIT = '2500'; }); @@ -41,7 +58,7 @@ describe('API Lambda handler', () => { }); it('uses process.env.MAX_LIMIT for max limit set', async () => { - expect(getEnvars().maxLimit).toBe(2500); + expect(getMaxLimit().maxLimit).toBe(2500); }); it('returns 200 OK with basic paginated resources', async () => { @@ -74,6 +91,7 @@ describe('API Lambda handler', () => { headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}}); const context = mockDeep(); const callback = jest.fn(); + const result = await getLetters(event, context, callback); const expected = { @@ -103,6 +121,7 @@ describe('API Lambda handler', () => { }); it("returns 400 if the limit parameter is not a number", async () => { + const event = makeApiGwEvent({ path: "/letters", queryStringParameters: { limit: "1%" }, @@ -113,7 +132,7 @@ describe('API Lambda handler', () => { const callback = jest.fn(); const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -129,7 +148,7 @@ describe('API Lambda handler', () => { const result = await getLetters(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getEnvars().maxLimit] }), 'correlationId'); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -144,7 +163,7 @@ describe('API Lambda handler', () => { const result = await getLetters(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getEnvars().maxLimit] }), 'correlationId'); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -159,7 +178,7 @@ describe('API Lambda handler', () => { const result = await getLetters(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getEnvars().maxLimit] }), 'correlationId'); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -173,7 +192,7 @@ describe('API Lambda handler', () => { const callback = jest.fn(); const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -183,7 +202,7 @@ describe('API Lambda handler', () => { const callback = jest.fn(); const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -197,7 +216,7 @@ describe('API Lambda handler', () => { const callback = jest.fn(); const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 83f122ec..3053c181 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -1,23 +1,28 @@ -import { patchLetter } from '../../index'; -import { APIGatewayProxyResult, Context } from 'aws-lambda'; -import { mockDeep } from 'jest-mock-extended'; -import { makeApiGwEvent } from './utils/test-utils'; +// mock dependencies +jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); +import { Deps, getDeps } from "../../config/deps"; +const mockedGetDeps = getDeps as jest.Mock; +const fakeDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + } as unknown as LambdaEnv +} +mockedGetDeps.mockReturnValue(fakeDeps); + +// mock service +jest.mock('../../services/letter-operations'); import * as letterService from '../../services/letter-operations'; -import { PatchLetterRequest, PatchLetterResponse } from '../../contracts/letters'; -import { mapErrorToResponse } from '../../mappers/error-mapper'; -import { ValidationError } from '../../errors'; -import * as errors from '../../contracts/errors'; +const mockedPatchLetterStatus = jest.mocked(letterService.patchLetterStatus); -jest.mock('../../services/letter-operations'); +// mock mapper jest.mock('../../mappers/error-mapper'); - -jest.mock('../../config/lambda-config', () => ({ - lambdaConfig: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id' - } -})); - +import { mapErrorToResponse } from '../../mappers/error-mapper'; const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, @@ -25,7 +30,17 @@ const expectedErrorResponse: APIGatewayProxyResult = { }; mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); -const mockedPatchLetterStatus = jest.mocked(letterService.patchLetterStatus); +import { APIGatewayProxyResult, Context } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { makeApiGwEvent } from './utils/test-utils'; +import { PatchLetterRequest, PatchLetterResponse } from '../../contracts/letters'; +import { ValidationError } from '../../errors'; +import * as errors from '../../contracts/errors'; +import { S3Client } from '@aws-sdk/client-s3'; +import pino from 'pino'; +import { LetterRepository } from '../../../../../internal/datastore/src'; +import { LambdaEnv } from '../../config/env'; +import { patchLetter } from '../patch-letter'; const updateLetterStatusRequest : PatchLetterRequest = { data: { @@ -91,7 +106,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -105,7 +120,7 @@ describe('patchLetter API Handler', () => { const callback = jest.fn(); const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -124,7 +139,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -140,7 +155,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -156,7 +171,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -172,7 +187,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -193,7 +208,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); spy.mockRestore(); @@ -211,7 +226,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); @@ -227,7 +242,7 @@ describe('patchLetter API Handler', () => { const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 7a6a5adf..0c75b838 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -1,13 +1,12 @@ import { APIGatewayProxyHandler } from "aws-lambda"; -import { createLetterRepository } from "../infrastructure/letter-repo-factory"; import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; -import { lambdaConfig } from "../config/lambda-config"; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; import { getLetterDataUrl } from "../services/letter-operations"; +import { Deps, getDeps } from "../config/deps"; -const letterRepo = createLetterRepository(); +const deps: Deps = getDeps(); export const getLetterData: APIGatewayProxyHandler = async (event) => { @@ -16,19 +15,22 @@ export const getLetterData: APIGatewayProxyHandler = async (event) => { try { assertNotEmpty(event.headers, new Error("The request headers are empty")); const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const letterId = assertNotEmpty( event.pathParameters?.id, + new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); return { statusCode: 303, headers: { - 'Location': await getLetterDataUrl(supplierId, letterId, letterRepo) + 'Location': await getLetterDataUrl(supplierId, letterId, deps) }, body: '' }; } catch (error) { - return mapErrorToResponse(error, correlationId); + return mapErrorToResponse(error, correlationId, deps.logger); } }; diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 1f6f7df9..53501c94 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,19 +1,15 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; import { getLettersForSupplier } from "../services/letter-operations"; -import { createLetterRepository } from "../infrastructure/letter-repo-factory"; import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; -import { lambdaConfig } from "../config/lambda-config"; -import pino from 'pino'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; import { mapToGetLettersResponse } from "../mappers/letter-mapper"; +import { Deps, getDeps } from "../config/deps"; -const letterRepo = createLetterRepository(); +const deps: Deps = getDeps(); -const log = pino(); - -export const getEnvars = (): { maxLimit: number } => ({ +export const getMaxLimit = (): { maxLimit: number } => ({ maxLimit: parseInt(process.env.MAX_LIMIT!) }); @@ -22,26 +18,28 @@ const status = "PENDING"; export const getLetters: APIGatewayProxyHandler = async (event) => { - const { maxLimit } = getEnvars(); + const { maxLimit } = getMaxLimit(); let correlationId; try { assertNotEmpty(event.headers, new Error("The request headers are empty")); const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit); const letters = await getLettersForSupplier( supplierId, status, limitNumber, - letterRepo, + deps.letterRepo, ); const response = mapToGetLettersResponse(letters); - log.info({ + deps.logger.info({ description: 'Pending letters successfully fetched', supplierId, limitNumber, @@ -55,7 +53,7 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { }; } catch (error) { - return mapErrorToResponse(error, correlationId); + return mapErrorToResponse(error, correlationId, deps.logger); } }; @@ -67,7 +65,7 @@ function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStrin function assertIsNumber(limitNumber: number) { if (isNaN(limitNumber)) { - log.info({ + deps.logger.info({ description: "limit parameter is not a number", limitNumber, }); @@ -77,7 +75,7 @@ function assertIsNumber(limitNumber: number) { function assertLimitInRange(limitNumber: number, maxLimit: number) { if (limitNumber <= 0 || limitNumber > maxLimit) { - log.info({ + deps.logger.info({ description: "Limit value is invalid", limitNumber, }); @@ -92,7 +90,7 @@ function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQuery (key) => key !== "limit" ) ) { - log.info({ + deps.logger.info({ description: "Unexpected query parameter(s) present", queryStringParameters: queryStringParameters, }); diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index c381d5bf..ecc61d51 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,15 +1,15 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; -import { createLetterRepository } from '../infrastructure/letter-repo-factory'; import { patchLetterStatus } from '../services/letter-operations'; import { PatchLetterRequest, PatchLetterRequestSchema } from '../contracts/letters'; import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; -import { lambdaConfig } from "../config/lambda-config"; import { assertNotEmpty, lowerCaseKeys } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; +import { Deps, getDeps } from "../config/deps"; + +const deps: Deps = getDeps(); -const letterRepo = createLetterRepository(); export const patchLetter: APIGatewayProxyHandler = async (event) => { let correlationId; @@ -17,9 +17,12 @@ export const patchLetter: APIGatewayProxyHandler = async (event) => { try { assertNotEmpty(event.headers, new Error('The request headers are empty')); const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const letterId = assertNotEmpty( event.pathParameters?.id, + new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); let patchLetterRequest: PatchLetterRequest; @@ -33,7 +36,7 @@ export const patchLetter: APIGatewayProxyHandler = async (event) => { else throw error; } - const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, letterRepo); + const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, deps.letterRepo); return { statusCode: 200, @@ -41,6 +44,6 @@ export const patchLetter: APIGatewayProxyHandler = async (event) => { }; } catch (error) { - return mapErrorToResponse(error, correlationId); + return mapErrorToResponse(error, correlationId, deps.logger); } }; diff --git a/lambdas/api-handler/src/infrastructure/letter-repo-factory.ts b/lambdas/api-handler/src/infrastructure/letter-repo-factory.ts deleted file mode 100644 index 15c69e21..00000000 --- a/lambdas/api-handler/src/infrastructure/letter-repo-factory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; -import pino from 'pino'; -import { LetterRepository } from '../../../../internal/datastore'; - -const BASE_TEN = 10; - -export function createLetterRepository(): LetterRepository { - const ddbClient = new DynamoDBClient({}); - const docClient = DynamoDBDocumentClient.from(ddbClient); - const log = pino(); - const config = { - lettersTableName: process.env.LETTERS_TABLE_NAME!, - ttlHours: parseInt(process.env.LETTER_TTL_HOURS!, BASE_TEN), - }; - - return new LetterRepository(docClient, log, config); -} diff --git a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts index 35e5aba9..5110e32b 100644 --- a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts @@ -1,12 +1,13 @@ import { mapErrorToResponse } from "../error-mapper"; import { ValidationError, NotFoundError } from "../../errors"; import { ApiErrorDetail } from "../../contracts/errors"; +import { Logger } from 'pino'; describe("mapErrorToResponse", () => { it("should map ValidationError to InvalidRequest response", () => { const err = new ValidationError(ApiErrorDetail.InvalidRequestLetterIdsMismatch); - const res = mapErrorToResponse(err, 'correlationId'); + const res = mapErrorToResponse(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(400); expect(JSON.parse(res.body)).toEqual({ @@ -28,7 +29,7 @@ describe("mapErrorToResponse", () => { it("should map NotFoundError to NotFound response", () => { const err = new NotFoundError(ApiErrorDetail.NotFoundLetterId); - const res = mapErrorToResponse(err, undefined); + const res = mapErrorToResponse(err, undefined, { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(404); expect(JSON.parse(res.body)).toEqual({ @@ -50,7 +51,7 @@ describe("mapErrorToResponse", () => { it("should map generic Error to InternalServerError response", () => { const err = new Error("Something broke"); - const res = mapErrorToResponse(err, 'correlationId'); + const res = mapErrorToResponse(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(500); expect(JSON.parse(res.body)).toEqual({ @@ -72,7 +73,7 @@ describe("mapErrorToResponse", () => { it("should map unexpected non-error to InternalServerError response", () => { const err = 12345; // not an Error - const res = mapErrorToResponse(err, 'correlationId'); + const res = mapErrorToResponse(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(500); expect(JSON.parse(res.body)).toEqual({ diff --git a/lambdas/api-handler/src/mappers/error-mapper.ts b/lambdas/api-handler/src/mappers/error-mapper.ts index 5a9f09d7..99493234 100644 --- a/lambdas/api-handler/src/mappers/error-mapper.ts +++ b/lambdas/api-handler/src/mappers/error-mapper.ts @@ -1,15 +1,14 @@ import { APIGatewayProxyResult } from "aws-lambda"; import { NotFoundError, ValidationError } from "../errors"; import { buildApiError, ApiErrorCode, ApiErrorDetail, ApiErrorTitle, ApiError, ApiErrorStatus } from "../contracts/errors"; -import pino from "pino"; import { v4 as uuid } from 'uuid'; +import { Logger } from "pino"; -const logger = pino(); export interface ErrorResponse { errors: ApiError[]; } -export function mapErrorToResponse(error: unknown, correlationId: string | undefined): APIGatewayProxyResult { +export function mapErrorToResponse(error: unknown, correlationId: string | undefined, logger: Logger): APIGatewayProxyResult { if (error instanceof ValidationError) { logger.info({ err: error }, `Validation error correlationId=${correlationId}`); return buildResponseFromErrorCode(ApiErrorCode.InvalidRequest, error.detail, correlationId); diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index c8538280..7175921f 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,41 +1,25 @@ -import { Letter } from '../../../../../internal/datastore/src'; +import { Letter, LetterRepository } from '../../../../../internal/datastore/src'; +import { Deps } from '../../config/deps'; import { LetterDto } from '../../contracts/letters'; import { getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations'; +import pino from 'pino'; +import { LambdaEnv } from '../../config/env'; jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn(), })); +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction; jest.mock('@aws-sdk/client-s3', () => { + const originalModule = jest.requireActual('@aws-sdk/client-s3'); return { - S3Client: jest.fn().mockImplementation(() => ({})), GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })), }; }); -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; - -const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction; +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; const MockedGetObjectCommand = GetObjectCommand as unknown as jest.Mock; -function makeLetter(id: string, status: Letter['status']) : Letter { - return { - id, - status, - supplierId: 'supplier1', - specificationId: 'spec123', - groupId: 'group123', - url: `s3://letterDataBucket/${id}.pdf`, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - supplierStatus: `supplier1#${status}`, - supplierStatusSk: Date.now().toString(), - ttl: 123, - reasonCode: 123, - reasonText: "Reason text" - }; -} - describe("getLetterIdsForSupplier", () => { it("returns letter IDs from the repository", async () => { const mockRepo = { @@ -124,14 +108,19 @@ describe('getLetterDataUrl function', () => { const updatedLetter = makeLetter("letter1", "REJECTED"); + const s3Client = { send: jest.fn() } as unknown as S3Client; + const letterRepo = { + getLetterById: jest.fn().mockResolvedValue(updatedLetter) + } as unknown as LetterRepository; + const logger = jest.fn() as unknown as pino.Logger;; + const env = jest.fn() as unknown as LambdaEnv; + const deps: Deps = { s3Client, letterRepo, logger, env }; + it('should return pre signed url successfully', async () => { - const mockRepo = { - getLetterById: jest.fn().mockResolvedValue(updatedLetter) - }; mockedGetSignedUrl.mockResolvedValue('http://somePreSignedUrl.com'); - const result = await getLetterDataUrl('supplier1', 'letter1', mockRepo as any); + const result = await getLetterDataUrl('supplier1', 'letter1', deps); expect(mockedGetSignedUrl).toHaveBeenCalled(); expect(MockedGetObjectCommand).toHaveBeenCalledWith({ @@ -143,19 +132,37 @@ describe('getLetterDataUrl function', () => { }); it('should throw notFoundError when letter does not exist', async () => { - const mockRepo = { + deps.letterRepo = { getLetterById: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1')) - }; + } as unknown as LetterRepository; - await expect(getLetterDataUrl('supplier1', 'letter42', mockRepo as any)).rejects.toThrow("No resource found with that ID"); + await expect(getLetterDataUrl('supplier1', 'letter42', deps)).rejects.toThrow("No resource found with that ID"); }); it('should throw unexpected error', async () => { - const mockRepo = { + deps.letterRepo = { getLetterById: jest.fn().mockRejectedValue(new Error('unexpected error')) - }; + } as unknown as LetterRepository; - await expect(getLetterDataUrl('supplier1', 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); + await expect(getLetterDataUrl('supplier1', 'letter1', deps)).rejects.toThrow("unexpected error"); }); }); + +function makeLetter(id: string, status: Letter['status']) : Letter { + return { + id, + status, + supplierId: 'supplier1', + specificationId: 'spec123', + groupId: 'group123', + url: `s3://letterDataBucket/${id}.pdf`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + supplierStatus: `supplier1#${status}`, + supplierStatusSk: Date.now().toString(), + ttl: 123, + reasonCode: 123, + reasonText: "Reason text" + }; +} diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 9763c059..b32f0356 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -5,6 +5,7 @@ import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; import { ApiErrorDetail } from '../contracts/errors'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { Deps } from '../config/deps'; export const getLettersForSupplier = async (supplierId: string, status: string, limit: number, letterRepo: LetterRepository): Promise => { @@ -32,13 +33,13 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str return mapToPatchLetterResponse(updatedLetter); } -export const getLetterDataUrl = async (supplierId: string, letterId: string, letterRepo: LetterRepository): Promise => { +export const getLetterDataUrl = async (supplierId: string, letterId: string, deps: Deps): Promise => { let letter; try { - letter = await letterRepo.getLetterById(supplierId, letterId); - return await getPresignedUrl(letter.url); + letter = await deps.letterRepo.getLetterById(supplierId, letterId); + return await getPresignedUrl(letter.url, deps.s3Client); } catch (error) { if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); @@ -47,8 +48,7 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, let } } -async function getPresignedUrl(s3Uri: string) { - const client = new S3Client(); +async function getPresignedUrl(s3Uri: string, s3Client: S3Client) { const url = new URL(s3Uri); // works for s3:// URIs const bucket = url.hostname; @@ -59,5 +59,5 @@ async function getPresignedUrl(s3Uri: string) { Key: key, }); - return await getSignedUrl(client, command, { expiresIn: 3600 }); + return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); } From 957c73215d3b48962b1a1c9f9245fa8f46535744 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Mon, 13 Oct 2025 14:24:17 +0000 Subject: [PATCH 22/31] Fix sonar attempt --- lambdas/api-handler/src/config/deps.ts | 2 +- lambdas/api-handler/src/handlers/get-letter-data.ts | 2 +- lambdas/api-handler/src/handlers/get-letters.ts | 3 ++- lambdas/api-handler/src/handlers/patch-letter.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index e28946df..dc753cd8 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -21,7 +21,7 @@ function createLetterRepository(log: pino.Logger, lambdaEnv: LambdaEnv): LetterR const docClient = DynamoDBDocumentClient.from(ddbClient); const config = { lettersTableName: lambdaEnv.LETTERS_TABLE_NAME, - ttlHours: parseInt(lambdaEnv.LETTER_TTL_HOURS, BASE_TEN), + ttlHours: Number.parseInt(lambdaEnv.LETTER_TTL_HOURS, BASE_TEN), }; return new LetterRepository(docClient, log, config); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 0c75b838..9268deeb 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -10,7 +10,7 @@ const deps: Deps = getDeps(); export const getLetterData: APIGatewayProxyHandler = async (event) => { - let correlationId; + let correlationId: string | undefined; try { assertNotEmpty(event.headers, new Error("The request headers are empty")); diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 53501c94..f0dbc628 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -19,7 +19,8 @@ const status = "PENDING"; export const getLetters: APIGatewayProxyHandler = async (event) => { const { maxLimit } = getMaxLimit(); - let correlationId; + + let correlationId: string | undefined; try { assertNotEmpty(event.headers, new Error("The request headers are empty")); diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index ecc61d51..cdb9b955 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -12,7 +12,7 @@ const deps: Deps = getDeps(); export const patchLetter: APIGatewayProxyHandler = async (event) => { - let correlationId; + let correlationId: string | undefined; try { assertNotEmpty(event.headers, new Error('The request headers are empty')); From aa36690117833db7f7da8409b49433948e6317e8 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Tue, 14 Oct 2025 13:33:24 +0000 Subject: [PATCH 23/31] Add tests for sonar coverage --- .../src/config/__tests__/deps.test.ts | 83 +++++++++++++++++++ .../src/config/__tests__/env.test.ts | 39 +++++++++ .../__tests__/get-letter-data.test.ts | 1 - 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 lambdas/api-handler/src/config/__tests__/deps.test.ts create mode 100644 lambdas/api-handler/src/config/__tests__/env.test.ts diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts new file mode 100644 index 00000000..7af5916e --- /dev/null +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -0,0 +1,83 @@ + +import type { Deps } from '../deps'; + +describe('getDeps()', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + // pino + jest.mock('pino', () => ({ + __esModule: true, + default: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + })), + })); + + jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(), + })); + + // Repo client + jest.mock('../../../../../internal/datastore', () => ({ + LetterRepository: jest.fn(), + })); + + // Env + jest.mock('../env', () => ({ + lambdaEnv: { + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: '24', + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + }, + })); + }); + + test('constructs deps and wires repository config correctly', async () => { + // get current mock instances + const { S3Client } = jest.requireMock('@aws-sdk/client-s3') as { S3Client: jest.Mock }; + const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; + const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock }; + + const { getDeps } = require('../deps'); + const deps: Deps = getDeps(); + + expect(S3Client).toHaveBeenCalledTimes(1); + expect(pinoMock.default).toHaveBeenCalledTimes(1); + + expect(LetterRepository).toHaveBeenCalledTimes(1); + const repoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0]; + expect(repoCtorArgs[2]).toEqual({ + lettersTableName: 'LettersTable', + ttlHours: 24 + }); + + expect(deps.env).toEqual({ + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: '24', + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + }); + }); + + test('is a singleton (second call returns the same object; constructors not re-run)', async () => { + // get current mock instances + const { S3Client } = jest.requireMock('@aws-sdk/client-s3') as { S3Client: jest.Mock }; + const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; + const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock }; + + const { getDeps } = require('../deps'); + + const first = getDeps(); + const second = getDeps(); + + expect(first).toBe(second); + expect(S3Client).toHaveBeenCalledTimes(1); + expect(LetterRepository).toHaveBeenCalledTimes(1); + expect(pinoMock.default).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts new file mode 100644 index 00000000..74873c1b --- /dev/null +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -0,0 +1,39 @@ +describe('lambdaEnv', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); // Clears cached modules + process.env = { ...OLD_ENV }; // Clone original env + }); + + afterAll(() => { + process.env = OLD_ENV; // Restore + }); + + it('should load all environment variables successfully', () => { + process.env.SUPPLIER_ID_HEADER = 'x-supplier-id'; + process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; + process.env.LETTERS_TABLE_NAME = 'letters-table'; + process.env.LETTER_TTL_HOURS = '24'; + + const { lambdaEnv } = require('../env'); + + expect(lambdaEnv).toEqual({ + SUPPLIER_ID_HEADER: 'x-supplier-id', + APIM_CORRELATION_HEADER: 'x-correlation-id', + LETTERS_TABLE_NAME: 'letters-table', + LETTER_TTL_HOURS: '24' + }); + }); + + it('should throw if a required env var is missing', () => { + process.env.SUPPLIER_ID_HEADER = 'x-supplier-id'; + process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; + process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var + process.env.LETTER_TTL_HOURS = '24'; + + expect(() => require('../env')).toThrow( + 'Missing required env var: LETTERS_TABLE_NAME' + ); + }); +}); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index c71a59c3..bb7ca01d 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -44,7 +44,6 @@ describe('API Lambda handler', () => { beforeEach(() => { jest.clearAllMocks(); - jest.resetModules(); }); it('returns 303 Found with a pre signed url', async () => { From 8fcb28f5af83c5c39aa1cf9381856dc30d3a3e21 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Wed, 15 Oct 2025 15:58:23 +0000 Subject: [PATCH 24/31] Add url ttl as config --- .../terraform/components/api/locals.tf | 5 +-- .../src/config/__tests__/deps.test.ts | 2 ++ .../src/config/__tests__/env.test.ts | 5 ++- lambdas/api-handler/src/config/env.ts | 4 ++- .../__tests__/get-letter-data.test.ts | 3 +- .../handlers/__tests__/get-letters.test.ts | 3 +- .../handlers/__tests__/patch-letter.test.ts | 3 +- .../__tests__/letter-operations.test.ts | 34 ++++++++++++++----- .../src/services/letter-operations.ts | 6 ++-- 9 files changed, 47 insertions(+), 18 deletions(-) diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index a46a2cfc..6b466e9d 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -18,7 +18,8 @@ locals { common_lambda_env_vars = { LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, LETTER_TTL_HOURS = 24, - SUPPLIER_ID_HEADER = "nhsd-supplier-id" - APIM_CORRELATION_HEADER = "nhsd-correlation-id" + SUPPLIER_ID_HEADER = "nhsd-supplier-id", + APIM_CORRELATION_HEADER = "nhsd-correlation-id", + DOWNLOAD_URL_TTL_SECONDS = 3600 } } diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index 7af5916e..57a945eb 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -33,6 +33,7 @@ describe('getDeps()', () => { LETTER_TTL_HOURS: '24', SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: '3600' }, })); }); @@ -61,6 +62,7 @@ describe('getDeps()', () => { LETTER_TTL_HOURS: '24', SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: '3600' }); }); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index 74873c1b..1d2dd0ae 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -15,6 +15,7 @@ describe('lambdaEnv', () => { process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; process.env.LETTERS_TABLE_NAME = 'letters-table'; process.env.LETTER_TTL_HOURS = '24'; + process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; const { lambdaEnv } = require('../env'); @@ -22,7 +23,8 @@ describe('lambdaEnv', () => { SUPPLIER_ID_HEADER: 'x-supplier-id', APIM_CORRELATION_HEADER: 'x-correlation-id', LETTERS_TABLE_NAME: 'letters-table', - LETTER_TTL_HOURS: '24' + LETTER_TTL_HOURS: '24', + DOWNLOAD_URL_TTL_SECONDS: '3600' }); }); @@ -31,6 +33,7 @@ describe('lambdaEnv', () => { process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var process.env.LETTER_TTL_HOURS = '24'; + process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; expect(() => require('../env')).toThrow( 'Missing required env var: LETTERS_TABLE_NAME' diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index 95d0a038..8fdb2e6b 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -3,13 +3,15 @@ export interface LambdaEnv { APIM_CORRELATION_HEADER: string; LETTERS_TABLE_NAME: string; LETTER_TTL_HOURS: string; + DOWNLOAD_URL_TTL_SECONDS: string; } export const lambdaEnv: LambdaEnv = { SUPPLIER_ID_HEADER: getEnv('SUPPLIER_ID_HEADER')!, APIM_CORRELATION_HEADER: getEnv('APIM_CORRELATION_HEADER')!, LETTERS_TABLE_NAME: getEnv('LETTERS_TABLE_NAME')!, - LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS')! + LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS')!, + DOWNLOAD_URL_TTL_SECONDS: getEnv('DOWNLOAD_URL_TTL_SECONDS')! }; function getEnv(name: string, required = true): string | undefined { diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index bb7ca01d..9db22477 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -24,7 +24,8 @@ const fakeDeps: jest.Mocked = { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', + DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' } as unknown as LambdaEnv } mockedGetDeps.mockReturnValue(fakeDeps); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index 1e8e0876..e7e78c73 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -10,7 +10,8 @@ const fakeDeps: jest.Mocked = { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', + DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' } as unknown as LambdaEnv } mockedGetDeps.mockReturnValue(fakeDeps); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 3053c181..8e1960fa 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -10,7 +10,8 @@ const fakeDeps: jest.Mocked = { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS' + LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', + DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' } as unknown as LambdaEnv } mockedGetDeps.mockReturnValue(fakeDeps); diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 7175921f..15b341bf 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -3,13 +3,11 @@ import { Deps } from '../../config/deps'; import { LetterDto } from '../../contracts/letters'; import { getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations'; import pino from 'pino'; -import { LambdaEnv } from '../../config/env'; jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn(), })); import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction; jest.mock('@aws-sdk/client-s3', () => { const originalModule = jest.requireActual('@aws-sdk/client-s3'); @@ -18,9 +16,13 @@ jest.mock('@aws-sdk/client-s3', () => { }; }); import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; -const MockedGetObjectCommand = GetObjectCommand as unknown as jest.Mock; describe("getLetterIdsForSupplier", () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + it("returns letter IDs from the repository", async () => { const mockRepo = { getLettersBySupplier: jest.fn().mockResolvedValue([ @@ -50,6 +52,10 @@ describe("getLetterIdsForSupplier", () => { describe('patchLetterStatus function', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const updatedLetterDto: LetterDto = { id: 'letter1', supplierId: 'supplier1', @@ -106,6 +112,13 @@ describe('patchLetterStatus function', () => { describe('getLetterDataUrl function', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction; + const MockedGetObjectCommand = GetObjectCommand as unknown as jest.Mock; + const updatedLetter = makeLetter("letter1", "REJECTED"); const s3Client = { send: jest.fn() } as unknown as S3Client; @@ -113,7 +126,13 @@ describe('getLetterDataUrl function', () => { getLetterById: jest.fn().mockResolvedValue(updatedLetter) } as unknown as LetterRepository; const logger = jest.fn() as unknown as pino.Logger;; - const env = jest.fn() as unknown as LambdaEnv; + const env = { + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: '24', + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: '3600' + }; const deps: Deps = { s3Client, letterRepo, logger, env }; it('should return pre signed url successfully', async () => { @@ -122,12 +141,11 @@ describe('getLetterDataUrl function', () => { const result = await getLetterDataUrl('supplier1', 'letter1', deps); - expect(mockedGetSignedUrl).toHaveBeenCalled(); - expect(MockedGetObjectCommand).toHaveBeenCalledWith({ + const expectedCommandInput = { Bucket: 'letterDataBucket', Key: 'letter1.pdf' - }); - + }; + expect(mockedGetSignedUrl).toHaveBeenCalledWith(s3Client, { input: expectedCommandInput}, { expiresIn: 3600}); expect(result).toEqual('http://somePreSignedUrl.com'); }); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index b32f0356..dc634121 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -39,7 +39,7 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, dep try { letter = await deps.letterRepo.getLetterById(supplierId, letterId); - return await getPresignedUrl(letter.url, deps.s3Client); + return await getDownloadUrl(letter.url, deps.s3Client, deps.env.DOWNLOAD_URL_TTL_SECONDS); } catch (error) { if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); @@ -48,7 +48,7 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, dep } } -async function getPresignedUrl(s3Uri: string, s3Client: S3Client) { +async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: string) { const url = new URL(s3Uri); // works for s3:// URIs const bucket = url.hostname; @@ -59,5 +59,5 @@ async function getPresignedUrl(s3Uri: string, s3Client: S3Client) { Key: key, }); - return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + return await getSignedUrl(s3Client, command, { expiresIn: Number.parseInt(expiry) }); } From b7e6fc6be183ce0f366372282f044242d8651bc4 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 16 Oct 2025 08:57:40 +0000 Subject: [PATCH 25/31] Add peer review suggestions --- .../terraform/components/api/module_lambda_get_letters.tf | 4 +--- lambdas/api-handler/src/config/deps.ts | 4 +--- lambdas/api-handler/src/config/env.ts | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/infrastructure/terraform/components/api/module_lambda_get_letters.tf b/infrastructure/terraform/components/api/module_lambda_get_letters.tf index 2695a8f8..23d5c6c5 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letters.tf @@ -60,10 +60,8 @@ data "aws_iam_policy_document" "get_letters_lambda" { effect = "Allow" actions = [ - "dynamodb:BatchGetItem", "dynamodb:GetItem", - "dynamodb:Query", - "dynamodb:Scan", + "dynamodb:Query" ] resources = [ diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index dc753cd8..97b95643 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -5,8 +5,6 @@ import pino from 'pino'; import { LetterRepository } from '../../../../internal/datastore'; import { lambdaEnv, LambdaEnv } from "../config/env"; -const BASE_TEN = 10; - let singletonDeps: Deps | null = null; export type Deps = { @@ -21,7 +19,7 @@ function createLetterRepository(log: pino.Logger, lambdaEnv: LambdaEnv): LetterR const docClient = DynamoDBDocumentClient.from(ddbClient); const config = { lettersTableName: lambdaEnv.LETTERS_TABLE_NAME, - ttlHours: Number.parseInt(lambdaEnv.LETTER_TTL_HOURS, BASE_TEN), + ttlHours: Number.parseInt(lambdaEnv.LETTER_TTL_HOURS) }; return new LetterRepository(docClient, log, config); diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index 8fdb2e6b..384c501d 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -14,9 +14,9 @@ export const lambdaEnv: LambdaEnv = { DOWNLOAD_URL_TTL_SECONDS: getEnv('DOWNLOAD_URL_TTL_SECONDS')! }; -function getEnv(name: string, required = true): string | undefined { +function getEnv(name: string): string { const value = process.env[name]; - if (!value && required) { + if (!value) { throw new Error(`Missing required env var: ${name}`); } return value; From bfdc5fd34aa591bef811d09d0d3e6adb5d89b0fa Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 16 Oct 2025 11:01:24 +0000 Subject: [PATCH 26/31] Remove non null assertion from env --- lambdas/api-handler/src/config/env.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index 384c501d..f1435d58 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -7,11 +7,11 @@ export interface LambdaEnv { } export const lambdaEnv: LambdaEnv = { - SUPPLIER_ID_HEADER: getEnv('SUPPLIER_ID_HEADER')!, - APIM_CORRELATION_HEADER: getEnv('APIM_CORRELATION_HEADER')!, - LETTERS_TABLE_NAME: getEnv('LETTERS_TABLE_NAME')!, - LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS')!, - DOWNLOAD_URL_TTL_SECONDS: getEnv('DOWNLOAD_URL_TTL_SECONDS')! + SUPPLIER_ID_HEADER: getEnv('SUPPLIER_ID_HEADER'), + APIM_CORRELATION_HEADER: getEnv('APIM_CORRELATION_HEADER'), + LETTERS_TABLE_NAME: getEnv('LETTERS_TABLE_NAME'), + LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS'), + DOWNLOAD_URL_TTL_SECONDS: getEnv('DOWNLOAD_URL_TTL_SECONDS') }; function getEnv(name: string): string { From fbb28c0bae6bdb725c8d90600c8c00b468188c96 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Thu, 16 Oct 2025 11:13:06 +0000 Subject: [PATCH 27/31] Fix lambdas ddb permissions --- .../terraform/components/api/module_lambda_get_letter_data.tf | 4 +--- .../terraform/components/api/module_lambda_get_letters.tf | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf index 05feda01..48cecad3 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -58,10 +58,8 @@ data "aws_iam_policy_document" "get_letter_data_lambda" { effect = "Allow" actions = [ - "dynamodb:BatchGetItem", "dynamodb:GetItem", - "dynamodb:Query", - "dynamodb:Scan", + "dynamodb:Query" ] resources = [ diff --git a/infrastructure/terraform/components/api/module_lambda_get_letters.tf b/infrastructure/terraform/components/api/module_lambda_get_letters.tf index 23d5c6c5..2695a8f8 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letters.tf @@ -60,8 +60,10 @@ data "aws_iam_policy_document" "get_letters_lambda" { effect = "Allow" actions = [ + "dynamodb:BatchGetItem", "dynamodb:GetItem", - "dynamodb:Query" + "dynamodb:Query", + "dynamodb:Scan", ] resources = [ From a4c7f265f5d6ef9d9f243e7c09faa021ffb25a6f Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Sun, 19 Oct 2025 21:45:28 +0000 Subject: [PATCH 28/31] Add dependency container --- .../src/config/__tests__/deps.test.ts | 33 +--- .../src/config/__tests__/env.test.ts | 14 +- lambdas/api-handler/src/config/deps.ts | 24 ++- lambdas/api-handler/src/config/env.ts | 32 ++-- .../__tests__/get-letter-data.test.ts | 54 +++---- .../handlers/__tests__/get-letters.test.ts | 84 ++++++----- .../handlers/__tests__/patch-letter.test.ts | 93 ++++++------ .../src/handlers/get-letter-data.ts | 48 +++--- .../api-handler/src/handlers/get-letters.ts | 142 +++++++++--------- .../api-handler/src/handlers/patch-letter.ts | 62 ++++---- lambdas/api-handler/src/index.ts | 14 +- .../__tests__/letter-operations.test.ts | 4 +- .../src/services/letter-operations.ts | 4 +- 13 files changed, 302 insertions(+), 306 deletions(-) diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index 57a945eb..663cd097 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -1,7 +1,7 @@ import type { Deps } from '../deps'; -describe('getDeps()', () => { +describe('createDependenciesContainer', () => { beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); @@ -28,12 +28,12 @@ describe('getDeps()', () => { // Env jest.mock('../env', () => ({ - lambdaEnv: { + envVars: { LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: '24', + LETTER_TTL_HOURS: 24, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: '3600' + DOWNLOAD_URL_TTL_SECONDS: 3600 }, })); }); @@ -44,8 +44,8 @@ describe('getDeps()', () => { const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock }; - const { getDeps } = require('../deps'); - const deps: Deps = getDeps(); + const { createDependenciesContainer } = require('../deps'); + const deps: Deps = createDependenciesContainer(); expect(S3Client).toHaveBeenCalledTimes(1); expect(pinoMock.default).toHaveBeenCalledTimes(1); @@ -59,27 +59,10 @@ describe('getDeps()', () => { expect(deps.env).toEqual({ LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: '24', + LETTER_TTL_HOURS: 24, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: '3600' + DOWNLOAD_URL_TTL_SECONDS: 3600 }); }); - - test('is a singleton (second call returns the same object; constructors not re-run)', async () => { - // get current mock instances - const { S3Client } = jest.requireMock('@aws-sdk/client-s3') as { S3Client: jest.Mock }; - const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; - const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock }; - - const { getDeps } = require('../deps'); - - const first = getDeps(); - const second = getDeps(); - - expect(first).toBe(second); - expect(S3Client).toHaveBeenCalledTimes(1); - expect(LetterRepository).toHaveBeenCalledTimes(1); - expect(pinoMock.default).toHaveBeenCalledTimes(1); - }); }); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index 1d2dd0ae..40eed0b7 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -1,3 +1,5 @@ +import { ZodError } from 'zod'; + describe('lambdaEnv', () => { const OLD_ENV = process.env; @@ -17,14 +19,14 @@ describe('lambdaEnv', () => { process.env.LETTER_TTL_HOURS = '24'; process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; - const { lambdaEnv } = require('../env'); + const { envVars } = require('../env'); - expect(lambdaEnv).toEqual({ + expect(envVars).toEqual({ SUPPLIER_ID_HEADER: 'x-supplier-id', APIM_CORRELATION_HEADER: 'x-correlation-id', LETTERS_TABLE_NAME: 'letters-table', - LETTER_TTL_HOURS: '24', - DOWNLOAD_URL_TTL_SECONDS: '3600' + LETTER_TTL_HOURS: 24, + DOWNLOAD_URL_TTL_SECONDS: 3600 }); }); @@ -35,8 +37,6 @@ describe('lambdaEnv', () => { process.env.LETTER_TTL_HOURS = '24'; process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; - expect(() => require('../env')).toThrow( - 'Missing required env var: LETTERS_TABLE_NAME' - ); + expect(() => require('../env')).toThrow(ZodError); }); }); diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index 97b95643..6f9c3e84 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -3,40 +3,34 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import pino from 'pino'; import { LetterRepository } from '../../../../internal/datastore'; -import { lambdaEnv, LambdaEnv } from "../config/env"; - -let singletonDeps: Deps | null = null; +import { envVars, EnvVars } from "../config/env"; export type Deps = { s3Client: S3Client; letterRepo: LetterRepository; logger: pino.Logger, - env: LambdaEnv + env: EnvVars }; -function createLetterRepository(log: pino.Logger, lambdaEnv: LambdaEnv): LetterRepository { +function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository { const ddbClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(ddbClient); const config = { - lettersTableName: lambdaEnv.LETTERS_TABLE_NAME, - ttlHours: Number.parseInt(lambdaEnv.LETTER_TTL_HOURS) + lettersTableName: envVars.LETTERS_TABLE_NAME, + ttlHours: envVars.LETTER_TTL_HOURS }; return new LetterRepository(docClient, log, config); } -export function getDeps(): Deps { - - if (singletonDeps) return singletonDeps; +export function createDependenciesContainer(): Deps { const log = pino(); - singletonDeps = { + return { s3Client: new S3Client(), - letterRepo: createLetterRepository(log, lambdaEnv), + letterRepo: createLetterRepository(log, envVars), logger: log, - env: lambdaEnv + env: envVars }; - - return singletonDeps; } diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index f1435d58..b2964ac8 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -1,23 +1,13 @@ -export interface LambdaEnv { - SUPPLIER_ID_HEADER: string; - APIM_CORRELATION_HEADER: string; - LETTERS_TABLE_NAME: string; - LETTER_TTL_HOURS: string; - DOWNLOAD_URL_TTL_SECONDS: string; -} +import {z} from 'zod'; -export const lambdaEnv: LambdaEnv = { - SUPPLIER_ID_HEADER: getEnv('SUPPLIER_ID_HEADER'), - APIM_CORRELATION_HEADER: getEnv('APIM_CORRELATION_HEADER'), - LETTERS_TABLE_NAME: getEnv('LETTERS_TABLE_NAME'), - LETTER_TTL_HOURS: getEnv('LETTER_TTL_HOURS'), - DOWNLOAD_URL_TTL_SECONDS: getEnv('DOWNLOAD_URL_TTL_SECONDS') -}; +const EnvVarsSchema = z.object({ + SUPPLIER_ID_HEADER: z.string(), + APIM_CORRELATION_HEADER: z.string(), + LETTERS_TABLE_NAME: z.string(), + LETTER_TTL_HOURS: z.coerce.number().int(), + DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int() +}); -function getEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index 9db22477..9f2b4efe 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -12,37 +12,33 @@ mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); jest.mock('../../services/letter-operations'); import * as letterService from '../../services/letter-operations'; -// mock dependencies -jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); -import { Deps, getDeps } from "../../config/deps"; -const mockedGetDeps = getDeps as jest.Mock; -const fakeDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', - DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' - } as unknown as LambdaEnv -} -mockedGetDeps.mockReturnValue(fakeDeps); - import type { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; import { makeApiGwEvent } from './utils/test-utils'; import { ValidationError } from '../../errors'; import * as errors from '../../contracts/errors'; -import { getLetterData } from '../get-letter-data'; +import { createGetLetterDataHandler } from '../get-letter-data'; import { S3Client } from '@aws-sdk/client-s3'; import pino from 'pino'; import { LetterRepository } from '../../../../../internal/datastore/src'; -import { LambdaEnv } from '../../config/env'; +import { EnvVars } from '../../config/env'; +import { Deps } from "../../config/deps"; describe('API Lambda handler', () => { + const mockedDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 1, + DOWNLOAD_URL_TTL_SECONDS: 1 + } as unknown as EnvVars + } + beforeEach(() => { jest.clearAllMocks(); }); @@ -59,7 +55,8 @@ describe('API Lambda handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await getLetterData(event, context, callback); + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); expect(result).toEqual({ statusCode: 303, @@ -77,9 +74,10 @@ describe('API Lambda handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await getLetterData(event, context, callback); + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -93,9 +91,10 @@ describe('API Lambda handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await getLetterData(event, context, callback); + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -107,9 +106,10 @@ describe('API Lambda handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await getLetterData(event, context, callback); + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index e7e78c73..89d8332e 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -1,21 +1,3 @@ -// mock dependencies -jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); -import { Deps, getDeps } from "../../config/deps"; -const mockedGetDeps = getDeps as jest.Mock; -const fakeDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', - DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' - } as unknown as LambdaEnv -} -mockedGetDeps.mockReturnValue(fakeDeps); - // mock error mapper jest.mock('../../mappers/error-mapper'); import { mapErrorToResponse } from '../../mappers/error-mapper'; @@ -39,11 +21,25 @@ import * as errors from '../../contracts/errors'; import { S3Client } from '@aws-sdk/client-s3'; import pino from 'pino'; import { LetterRepository } from '../../../../../internal/datastore/src'; -import { LambdaEnv } from '../../config/env'; -import { getLetters } from "../get-letters"; +import { createGetLettersHandler } from "../get-letters"; +import { Deps } from "../../config/deps"; +import { EnvVars } from '../../config/env'; describe('API Lambda handler', () => { + const mockedDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 1, + DOWNLOAD_URL_TTL_SECONDS: 1 + } as unknown as EnvVars + } + const originalEnv = process.env; beforeEach(() => { @@ -93,7 +89,8 @@ describe('API Lambda handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); const expected = { data: [ @@ -128,12 +125,14 @@ describe('API Lambda handler', () => { queryStringParameters: { limit: "1%" }, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); - const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId', mockedGetDeps().logger); + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); + + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -143,13 +142,14 @@ describe('API Lambda handler', () => { queryStringParameters: { limit: "-1" }, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); - const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); + + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -161,10 +161,12 @@ describe('API Lambda handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); + + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -176,10 +178,12 @@ describe('API Lambda handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); + + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedGetDeps().logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -191,9 +195,11 @@ describe('API Lambda handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId', mockedGetDeps().logger); + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -201,9 +207,11 @@ describe('API Lambda handler', () => { const event = makeApiGwEvent({ path: "/letters", headers: {} }); const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -215,9 +223,11 @@ describe('API Lambda handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); + const getLettersHandler = createGetLettersHandler(mockedDeps); + const result = await getLettersHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 8e1960fa..0ec1954b 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -1,21 +1,3 @@ -// mock dependencies -jest.mock("../../config/deps", () => ({ getDeps: jest.fn() })); -import { Deps, getDeps } from "../../config/deps"; -const mockedGetDeps = getDeps as jest.Mock; -const fakeDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 'LETTER_TTL_HOURS', - DOWNLOAD_URL_TTL_SECONDS: 'DOWNLOAD_URL_TTL_SECONDS' - } as unknown as LambdaEnv -} -mockedGetDeps.mockReturnValue(fakeDeps); - // mock service jest.mock('../../services/letter-operations'); import * as letterService from '../../services/letter-operations'; @@ -40,8 +22,9 @@ import * as errors from '../../contracts/errors'; import { S3Client } from '@aws-sdk/client-s3'; import pino from 'pino'; import { LetterRepository } from '../../../../../internal/datastore/src'; -import { LambdaEnv } from '../../config/env'; -import { patchLetter } from '../patch-letter'; +import { EnvVars } from '../../config/env'; +import { createPatchLetterHandler } from '../patch-letter'; +import { Deps } from "../../config/deps"; const updateLetterStatusRequest : PatchLetterRequest = { data: { @@ -57,12 +40,25 @@ const updateLetterStatusRequest : PatchLetterRequest = { const requestBody = JSON.stringify(updateLetterStatusRequest, null, 2); -beforeEach(() => { - jest.clearAllMocks(); -}); - describe('patchLetter API Handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockedDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 1, + DOWNLOAD_URL_TTL_SECONDS: 1 + } as unknown as EnvVars +} + it('returns 200 OK with updated resource', async () => { const event = makeApiGwEvent({ path: '/letters/id1', @@ -88,7 +84,8 @@ describe('patchLetter API Handler', () => { }; mockedPatchLetterStatus.mockResolvedValue(updateLetterServiceResponse); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); expect(result).toEqual({ statusCode: 200, @@ -105,9 +102,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -119,9 +117,11 @@ describe('patchLetter API Handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedGetDeps().logger); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -138,9 +138,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -154,9 +155,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -170,9 +172,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -186,9 +189,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -207,9 +211,10 @@ describe('patchLetter API Handler', () => { throw error; }); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); spy.mockRestore(); @@ -225,9 +230,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -241,9 +247,10 @@ describe('patchLetter API Handler', () => { const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedGetDeps().logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 9268deeb..224a7d66 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -4,33 +4,35 @@ import { ApiErrorDetail } from '../contracts/errors'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; import { getLetterDataUrl } from "../services/letter-operations"; -import { Deps, getDeps } from "../config/deps"; +import type { Deps } from "../config/deps"; -const deps: Deps = getDeps(); -export const getLetterData: APIGatewayProxyHandler = async (event) => { +export function createGetLetterDataHandler(deps: Deps): APIGatewayProxyHandler { - let correlationId: string | undefined; + return async (event) => { - try { - assertNotEmpty(event.headers, new Error("The request headers are empty")); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - const letterId = assertNotEmpty( event.pathParameters?.id, - new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + let correlationId: string | undefined; - return { - statusCode: 303, - headers: { - 'Location': await getLetterDataUrl(supplierId, letterId, deps) - }, - body: '' - }; - } - catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); + try { + assertNotEmpty(event.headers, new Error("The request headers are empty")); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const letterId = assertNotEmpty( event.pathParameters?.id, + new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + + return { + statusCode: 303, + headers: { + 'Location': await getLetterDataUrl(supplierId, letterId, deps) + }, + body: '' + }; + } + catch (error) { + return mapErrorToResponse(error, correlationId, deps.logger); + } } }; diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index f0dbc628..ea3212a8 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -5,9 +5,8 @@ import { ApiErrorDetail } from '../contracts/errors'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; import { mapToGetLettersResponse } from "../mappers/letter-mapper"; -import { Deps, getDeps } from "../config/deps"; - -const deps: Deps = getDeps(); +import type { Deps } from "../config/deps"; +import { Logger } from 'pino'; export const getMaxLimit = (): { maxLimit: number } => ({ maxLimit: parseInt(process.env.MAX_LIMIT!) @@ -16,82 +15,65 @@ export const getMaxLimit = (): { maxLimit: number } => ({ // The endpoint should only return pending letters for now const status = "PENDING"; -export const getLetters: APIGatewayProxyHandler = async (event) => { - - const { maxLimit } = getMaxLimit(); - - let correlationId: string | undefined; - - try { - assertNotEmpty(event.headers, new Error("The request headers are empty")); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit); - - const letters = await getLettersForSupplier( - supplierId, - status, - limitNumber, - deps.letterRepo, - ); - - const response = mapToGetLettersResponse(letters); - - deps.logger.info({ - description: 'Pending letters successfully fetched', - supplierId, - limitNumber, - status, - lettersCount: letters.length - }); - - return { - statusCode: 200, - body: JSON.stringify(response, null, 2), - }; - } - catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); +export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { + + return async (event) => { + + const { maxLimit } = getMaxLimit(); + + let correlationId: string | undefined; + + try { + assertNotEmpty(event.headers, new Error("The request headers are empty")); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit, deps.logger); + + const letters = await getLettersForSupplier( + supplierId, + status, + limitNumber, + deps.letterRepo, + ); + + const response = mapToGetLettersResponse(letters); + + deps.logger.info({ + description: 'Pending letters successfully fetched', + supplierId, + limitNumber, + status, + lettersCount: letters.length + }); + + return { + statusCode: 200, + body: JSON.stringify(response, null, 2), + }; + } + catch (error) { + return mapErrorToResponse(error, correlationId, deps.logger); + } } }; -function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, maxLimit: number) : number { - - validateLimitParamOnly(queryStringParameters); - return getLimit(queryStringParameters?.limit, maxLimit); -} - -function assertIsNumber(limitNumber: number) { - if (isNaN(limitNumber)) { - deps.logger.info({ - description: "limit parameter is not a number", - limitNumber, - }); - throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotANumber); - } -} +function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, maxLimit: number, logger: Logger) : number { -function assertLimitInRange(limitNumber: number, maxLimit: number) { - if (limitNumber <= 0 || limitNumber > maxLimit) { - deps.logger.info({ - description: "Limit value is invalid", - limitNumber, - }); - throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [maxLimit]}); - } + validateLimitParamOnly(queryStringParameters, logger); + return getLimit(queryStringParameters?.limit, maxLimit, logger); } -function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null) { +function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, logger: Logger) { if ( queryStringParameters && Object.keys(queryStringParameters).some( (key) => key !== "limit" ) ) { - deps.logger.info({ + logger.info({ description: "Unexpected query parameter(s) present", queryStringParameters: queryStringParameters, }); @@ -99,15 +81,35 @@ function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQuery } } -function getLimit(limit: string | undefined, maxLimit: number) { +function getLimit(limit: string | undefined, maxLimit: number, logger: Logger) { let result; if (limit) { let limitParam = limit; result = Number(limitParam); - assertIsNumber(result); - assertLimitInRange(result, maxLimit); + assertIsNumber(result, logger); + assertLimitInRange(result, maxLimit, logger); } else { result = maxLimit; } return result; } + +function assertIsNumber(limitNumber: number, logger: Logger) { + if (isNaN(limitNumber)) { + logger.info({ + description: "limit parameter is not a number", + limitNumber, + }); + throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotANumber); + } +} + +function assertLimitInRange(limitNumber: number, maxLimit: number, logger: Logger) { + if (limitNumber <= 0 || limitNumber > maxLimit) { + logger.info({ + description: "Limit value is invalid", + limitNumber, + }); + throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [maxLimit]}); + } +} diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index cdb9b955..3247c4a7 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -6,44 +6,46 @@ import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; import { assertNotEmpty, lowerCaseKeys } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; -import { Deps, getDeps } from "../config/deps"; +import type { Deps } from "../config/deps"; -const deps: Deps = getDeps(); -export const patchLetter: APIGatewayProxyHandler = async (event) => { +export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { - let correlationId: string | undefined; + return async (event) => { - try { - assertNotEmpty(event.headers, new Error('The request headers are empty')); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); - const letterId = assertNotEmpty( event.pathParameters?.id, - new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); - const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); - - let patchLetterRequest: PatchLetterRequest; + let correlationId: string | undefined; try { - patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); - } catch (error) { - if (error instanceof Error) { - throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); + assertNotEmpty(event.headers, new Error('The request headers are empty')); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], + new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], + new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const letterId = assertNotEmpty( event.pathParameters?.id, + new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); + + let patchLetterRequest: PatchLetterRequest; + + try { + patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); + } catch (error) { + if (error instanceof Error) { + throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); + } + else throw error; } - else throw error; - } - const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, deps.letterRepo); + const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, deps.letterRepo); - return { - statusCode: 200, - body: JSON.stringify(result, null, 2) - }; + return { + statusCode: 200, + body: JSON.stringify(result, null, 2) + }; - } catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); - } + } catch (error) { + return mapErrorToResponse(error, correlationId, deps.logger); + } + }; }; diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index c32ac6d9..49a14008 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -1,4 +1,10 @@ -// Export all handlers for ease of access -export { getLetters } from './handlers/get-letters'; -export { getLetterData } from './handlers/get-letter-data'; -export { patchLetter } from './handlers/patch-letter'; +import { createDependenciesContainer } from "./config/deps"; +import { createGetLetterDataHandler } from "./handlers/get-letter-data"; +import { createGetLettersHandler } from "./handlers/get-letters"; +import { createPatchLetterHandler } from "./handlers/patch-letter"; + +const container = createDependenciesContainer(); + +export const getLetterData = createGetLetterDataHandler(container); +export const getLetters = createGetLettersHandler(container); +export const patchLetter = createPatchLetterHandler(container); diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 15b341bf..c6c3ace9 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -128,10 +128,10 @@ describe('getLetterDataUrl function', () => { const logger = jest.fn() as unknown as pino.Logger;; const env = { LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: '24', + LETTER_TTL_HOURS: 24, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: '3600' + DOWNLOAD_URL_TTL_SECONDS: 3600 }; const deps: Deps = { s3Client, letterRepo, logger, env }; diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index dc634121..f64b72f9 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -48,7 +48,7 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, dep } } -async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: string) { +async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: number) { const url = new URL(s3Uri); // works for s3:// URIs const bucket = url.hostname; @@ -59,5 +59,5 @@ async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: string) Key: key, }); - return await getSignedUrl(s3Client, command, { expiresIn: Number.parseInt(expiry) }); + return await getSignedUrl(s3Client, command, { expiresIn: expiry }); } From d37e73518c5986c9c2d39cc74246b5ad84ecfc59 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Sun, 19 Oct 2025 21:47:34 +0000 Subject: [PATCH 29/31] Bump shared lambda module --- infrastructure/terraform/components/api/README.md | 8 ++++---- .../terraform/components/api/module_authorizer_lambda.tf | 2 +- .../components/api/module_lambda_get_letter_data.tf | 2 +- .../terraform/components/api/module_lambda_get_letters.tf | 2 +- .../components/api/module_lambda_patch_letter.tf | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 6487f904..c05256f5 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -32,13 +32,13 @@ No requirements. | Name | Source | Version | |------|--------|---------| -| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | +| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | -| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | -| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | +| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | -| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | +| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-ssl.zip | n/a | ## Outputs diff --git a/infrastructure/terraform/components/api/module_authorizer_lambda.tf b/infrastructure/terraform/components/api/module_authorizer_lambda.tf index 19711a28..9d56c950 100644 --- a/infrastructure/terraform/components/api/module_authorizer_lambda.tf +++ b/infrastructure/terraform/components/api/module_authorizer_lambda.tf @@ -1,5 +1,5 @@ module "authorizer_lambda" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" aws_account_id = var.aws_account_id component = var.component diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf index 48cecad3..5bf0597d 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -1,5 +1,5 @@ module "get_letter_data" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" function_name = "get_letter_data" description = "Get the letter data" diff --git a/infrastructure/terraform/components/api/module_lambda_get_letters.tf b/infrastructure/terraform/components/api/module_lambda_get_letters.tf index 2695a8f8..fa2369c7 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letters.tf @@ -1,5 +1,5 @@ module "get_letters" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" function_name = "get_letters" description = "Get paginated letter ids" diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf index 568eff56..942335b5 100644 --- a/infrastructure/terraform/components/api/module_lambda_patch_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf @@ -1,5 +1,5 @@ module "patch_letter" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" function_name = "patch_letter" description = "Update the status of a letter" From 7d9a3f28e634abcaebe126403a2d3a283c7e9f73 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Sun, 19 Oct 2025 23:33:52 +0000 Subject: [PATCH 30/31] Refactor header validation --- .../src/handlers/get-letter-data.ts | 18 +++++----- .../api-handler/src/handlers/get-letters.ts | 22 ++++++------ .../api-handler/src/handlers/patch-letter.ts | 20 +++++------ lambdas/api-handler/src/utils/validation.ts | 34 +++++++++++++++++++ 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 224a7d66..547c8e17 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; -import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; +import { assertNotEmpty, validateCommonHeaders } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; @@ -11,28 +11,26 @@ export function createGetLetterDataHandler(deps: Deps): APIGatewayProxyHandler { return async (event) => { - let correlationId: string | undefined; + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } try { - assertNotEmpty(event.headers, new Error("The request headers are empty")); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); return { statusCode: 303, headers: { - 'Location': await getLetterDataUrl(supplierId, letterId, deps) + 'Location': await getLetterDataUrl(commonHeadersResult.value.supplierId, letterId, deps) }, body: '' }; } catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); } } }; diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index ea3212a8..6769d3bd 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,6 +1,6 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; import { getLettersForSupplier } from "../services/letter-operations"; -import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; +import { validateCommonHeaders } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; @@ -19,21 +19,19 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { return async (event) => { - const { maxLimit } = getMaxLimit(); + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } - let correlationId: string | undefined; + const { maxLimit } = getMaxLimit(); try { - assertNotEmpty(event.headers, new Error("The request headers are empty")); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit, deps.logger); const letters = await getLettersForSupplier( - supplierId, + commonHeadersResult.value.supplierId, status, limitNumber, deps.letterRepo, @@ -43,7 +41,7 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { deps.logger.info({ description: 'Pending letters successfully fetched', - supplierId, + supplierId: commonHeadersResult.value.supplierId, limitNumber, status, lettersCount: letters.length @@ -55,7 +53,7 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); } } }; diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index 3247c4a7..e8442fae 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -4,7 +4,7 @@ import { PatchLetterRequest, PatchLetterRequestSchema } from '../contracts/lette import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; -import { assertNotEmpty, lowerCaseKeys } from '../utils/validation'; +import { assertNotEmpty, validateCommonHeaders } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; import type { Deps } from "../config/deps"; @@ -13,15 +13,13 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { return async (event) => { - let correlationId: string | undefined; + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } try { - assertNotEmpty(event.headers, new Error('The request headers are empty')); - const lowerCasedHeaders = lowerCaseKeys(event.headers); - correlationId = assertNotEmpty(lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER], - new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER], - new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); @@ -37,15 +35,15 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { else throw error; } - const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, deps.letterRepo); + const updatedLetter = await patchLetterStatus(mapToLetterDto(patchLetterRequest, commonHeadersResult.value.supplierId), letterId, deps.letterRepo); return { statusCode: 200, - body: JSON.stringify(result, null, 2) + body: JSON.stringify(updatedLetter, null, 2) }; } catch (error) { - return mapErrorToResponse(error, correlationId, deps.logger); + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); } }; }; diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index 4bb50960..7d1e0e2e 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -1,3 +1,11 @@ +import { APIGatewayProxyEventHeaders } from "aws-lambda"; +import { EnvVars } from "../config/env"; +import { ValidationError } from "../errors"; +import { ApiErrorDetail } from "../contracts/errors"; +import { mapErrorToResponse } from "../mappers/error-mapper"; +import { getLetterDataUrl } from "../services/letter-operations"; +import { Deps } from "../config/deps"; + export function assertNotEmpty( value: T | null | undefined, error: Error @@ -20,3 +28,29 @@ export function assertNotEmpty( export function lowerCaseKeys(obj: Record): Record { return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); } + +export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps: Deps +): { ok: true; value: {correlationId: string, supplierId: string } } | { ok: false; error: Error; correlationId?: string } { + + if (!headers || Object.keys(headers).length === 0) { + return { ok: false, error: new Error("The request headers are empty") }; + } + + const lowerCasedHeaders = lowerCaseKeys(headers); + + const correlationId = lowerCasedHeaders[deps.env.APIM_CORRELATION_HEADER]; + if (!correlationId) { + return { ok: false, error: new Error("The request headers don't contain the APIM correlation id") }; + } + + const supplierId = lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER]; + if (!supplierId) { + return { + ok: false, + error: new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId), + correlationId, + }; + } + + return { ok: true, value: { correlationId, supplierId } }; +} From b91e208dc4035a0a419d002befb1af2c8da71294 Mon Sep 17 00:00:00 2001 From: Francisco Videira Date: Wed, 22 Oct 2025 09:22:03 +0000 Subject: [PATCH 31/31] Last peer review actions --- .../terraform/components/api/locals.tf | 4 +- .../src/config/__tests__/deps.test.ts | 10 +- .../src/config/__tests__/env.test.ts | 45 +++-- lambdas/api-handler/src/config/env.ts | 3 +- lambdas/api-handler/src/contracts/errors.ts | 1 - .../__tests__/get-letter-data.test.ts | 28 ++- .../handlers/__tests__/get-letters.test.ts | 179 +++++++++++------- .../handlers/__tests__/patch-letter.test.ts | 98 +++++++--- .../api-handler/src/handlers/get-letters.ts | 40 ++-- .../__tests__/letter-operations.test.ts | 6 +- lambdas/api-handler/src/utils/validation.ts | 30 +-- 11 files changed, 292 insertions(+), 152 deletions(-) diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 6b466e9d..c094c52f 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -17,9 +17,9 @@ locals { common_lambda_env_vars = { LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, - LETTER_TTL_HOURS = 24, + LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours SUPPLIER_ID_HEADER = "nhsd-supplier-id", APIM_CORRELATION_HEADER = "nhsd-correlation-id", - DOWNLOAD_URL_TTL_SECONDS = 3600 + DOWNLOAD_URL_TTL_SECONDS = 60 } } diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index 663cd097..2028db88 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -30,10 +30,10 @@ describe('createDependenciesContainer', () => { jest.mock('../env', () => ({ envVars: { LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: 24, + LETTER_TTL_HOURS: 12960, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: 3600 + DOWNLOAD_URL_TTL_SECONDS: 60 }, })); }); @@ -54,15 +54,15 @@ describe('createDependenciesContainer', () => { const repoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0]; expect(repoCtorArgs[2]).toEqual({ lettersTableName: 'LettersTable', - ttlHours: 24 + ttlHours: 12960 }); expect(deps.env).toEqual({ LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: 24, + LETTER_TTL_HOURS: 12960, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: 3600 + DOWNLOAD_URL_TTL_SECONDS: 60 }); }); }); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index 40eed0b7..c24205ab 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -13,30 +13,51 @@ describe('lambdaEnv', () => { }); it('should load all environment variables successfully', () => { - process.env.SUPPLIER_ID_HEADER = 'x-supplier-id'; - process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; + process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; + process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; process.env.LETTERS_TABLE_NAME = 'letters-table'; - process.env.LETTER_TTL_HOURS = '24'; - process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; + process.env.LETTER_TTL_HOURS = '12960'; + process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; + process.env.MAX_LIMIT = '2500'; const { envVars } = require('../env'); expect(envVars).toEqual({ - SUPPLIER_ID_HEADER: 'x-supplier-id', - APIM_CORRELATION_HEADER: 'x-correlation-id', + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'letters-table', - LETTER_TTL_HOURS: 24, - DOWNLOAD_URL_TTL_SECONDS: 3600 + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60, + MAX_LIMIT: 2500, }); }); it('should throw if a required env var is missing', () => { - process.env.SUPPLIER_ID_HEADER = 'x-supplier-id'; - process.env.APIM_CORRELATION_HEADER = 'x-correlation-id'; + process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; + process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var - process.env.LETTER_TTL_HOURS = '24'; - process.env.DOWNLOAD_URL_TTL_SECONDS = '3600'; + process.env.LETTER_TTL_HOURS = '12960'; + process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; expect(() => require('../env')).toThrow(ZodError); }); + + it('should not throw if optional are not set', () => { + process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id'; + process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id'; + process.env.LETTERS_TABLE_NAME = 'letters-table'; + process.env.LETTER_TTL_HOURS = '12960'; + process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; + + const { envVars } = require('../env'); + + expect(envVars).toEqual({ + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'letters-table', + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60, + MAX_LIMIT: undefined + }); + }); }); diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index b2964ac8..5dfe4d27 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -5,7 +5,8 @@ const EnvVarsSchema = z.object({ APIM_CORRELATION_HEADER: z.string(), LETTERS_TABLE_NAME: z.string(), LETTER_TTL_HOURS: z.coerce.number().int(), - DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int() + DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int(), + MAX_LIMIT: z.coerce.number().int().optional() }); export type EnvVars = z.infer; diff --git a/lambdas/api-handler/src/contracts/errors.ts b/lambdas/api-handler/src/contracts/errors.ts index 9dc98f60..f3b3114e 100644 --- a/lambdas/api-handler/src/contracts/errors.ts +++ b/lambdas/api-handler/src/contracts/errors.ts @@ -27,7 +27,6 @@ export enum ApiErrorStatus { export enum ApiErrorDetail { NotFoundLetterId = 'No resource found with that ID', - InvalidRequestMissingSupplierId = 'The supplier ID is missing from the request', InvalidRequestMissingBody = 'The request is missing the body', InvalidRequestMissingLetterIdPathParameter = 'The request is missing the letter id path parameter', InvalidRequestLetterIdsMismatch = 'The letter ID in the request body does not match the letter ID path parameter', diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index 9f2b4efe..5b6509a8 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -34,8 +34,8 @@ describe('API Lambda handler', () => { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 1, - DOWNLOAD_URL_TTL_SECONDS: 1 + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60 } as unknown as EnvVars } @@ -48,8 +48,13 @@ describe('API Lambda handler', () => { const mockedGetLetterDataUrlService = letterService.getLetterDataUrl as jest.Mock; mockedGetLetterDataUrlService.mockResolvedValue('https://somePreSignedUrl.com'); - const event = makeApiGwEvent({path: '/letters/letter1/data', - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}, + const event = makeApiGwEvent({ + path: '/letters/letter1/data', + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + }, pathParameters: {id: 'id1'} }); const context = mockDeep(); @@ -67,7 +72,7 @@ describe('API Lambda handler', () => { }); }); - it('returns 400 for missing supplier ID (empty headers)', async () => { + it('returns error if headers are empty', async () => { const event = makeApiGwEvent({ path: '/letters/letter1/data', headers: {}, pathParameters: {id: 'id1'} }); @@ -81,11 +86,14 @@ describe('API Lambda handler', () => { expect(result).toEqual(expectedErrorResponse); }); - it('returns 500 if correlation id not provided in request', async () => { + it('returns error if correlation id not provided in request', async () => { const event = makeApiGwEvent({ path: '/letters/letter1/data', queryStringParameters: { limit: '2000' }, - headers: {'nhsd-supplier-id': 'supplier1'}, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'x-request-id': 'requestId' + }, pathParameters: {id: 'id1'} }); const context = mockDeep(); @@ -101,7 +109,11 @@ describe('API Lambda handler', () => { it('returns error response when path parameter letterId is not found', async () => { const event = makeApiGwEvent({ path: '/letters/', - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + }, }); const context = mockDeep(); const callback = jest.fn(); diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index 89d8332e..73b52896 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -15,14 +15,13 @@ import * as letterService from '../../services/letter-operations'; import type { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; import { makeApiGwEvent } from './utils/test-utils'; -import { getMaxLimit } from '../get-letters'; import { ValidationError } from '../../errors'; import * as errors from '../../contracts/errors'; import { S3Client } from '@aws-sdk/client-s3'; import pino from 'pino'; import { LetterRepository } from '../../../../../internal/datastore/src'; -import { createGetLettersHandler } from "../get-letters"; -import { Deps } from "../../config/deps"; +import { createGetLettersHandler } from '../get-letters'; +import { Deps } from '../../config/deps'; import { EnvVars } from '../../config/env'; describe('API Lambda handler', () => { @@ -35,27 +34,14 @@ describe('API Lambda handler', () => { SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 1, - DOWNLOAD_URL_TTL_SECONDS: 1 + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60, + MAX_LIMIT: 2500 } as unknown as EnvVars } - const originalEnv = process.env; - beforeEach(() => { jest.clearAllMocks(); - jest.resetModules(); - - process.env = { ...originalEnv }; - process.env.MAX_LIMIT = '2500'; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('uses process.env.MAX_LIMIT for max limit set', async () => { - expect(getMaxLimit().maxLimit).toBe(2500); }); it('returns 200 OK with basic paginated resources', async () => { @@ -63,51 +49,59 @@ describe('API Lambda handler', () => { const mockedGetLetters = letterService.getLettersForSupplier as jest.Mock; mockedGetLetters.mockResolvedValue([ { - id: "l1", - specificationId: "s1", + id: 'l1', + specificationId: 's1', groupId: 'g1', - status: "PENDING" + status: 'PENDING' }, { - id: "l2", - specificationId: "s1", + id: 'l2', + specificationId: 's1', groupId: 'g1', - status: "PENDING", + status: 'PENDING', }, { - id: "l3", - specificationId: "s1", + id: 'l3', + specificationId: 's1', groupId: 'g1', - status: "PENDING", + status: 'PENDING', reasonCode: 123, // shouldn't be returned if present - reasonText: "Reason text" // shouldn't be returned if present + reasonText: 'Reason text' // shouldn't be returned if present }, ]); - const event = makeApiGwEvent({path: '/letters', - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}}); + const event = makeApiGwEvent({ + path: '/letters', + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); const context = mockDeep(); const callback = jest.fn(); const getLettersHandler = createGetLettersHandler(mockedDeps); const result = await getLettersHandler(event, context, callback); + expect(mockedGetLetters).toHaveBeenCalledWith('supplier1', 'PENDING', mockedDeps.env.MAX_LIMIT, mockedDeps.letterRepo); + const expected = { data: [ { - id: "l1", - type: "Letter", - attributes: { status: "PENDING", specificationId: "s1", groupId: 'g1' }, + id: 'l1', + type: 'Letter', + attributes: { status: 'PENDING', specificationId: 's1', groupId: 'g1' }, }, { - id: "l2", - type: "Letter", - attributes: { status: "PENDING", specificationId: "s1", groupId: 'g1' }, + id: 'l2', + type: 'Letter', + attributes: { status: 'PENDING', specificationId: 's1', groupId: 'g1' }, }, { - id: "l3", - type: "Letter", - attributes: { status: "PENDING", specificationId: "s1", groupId: 'g1' } + id: 'l3', + type: 'Letter', + attributes: { status: 'PENDING', specificationId: 's1', groupId: 'g1' } } ], }; @@ -118,12 +112,16 @@ describe('API Lambda handler', () => { }); }); - it("returns 400 if the limit parameter is not a number", async () => { + it('returns error if the limit parameter is not a number', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { limit: "1%" }, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + path: '/letters', + queryStringParameters: { limit: '1%' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -136,11 +134,15 @@ describe('API Lambda handler', () => { expect(result).toEqual(expectedErrorResponse); }); - it("returns 400 if the limit parameter is negative", async () => { + it('returns error if the limit parameter is negative', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { limit: "-1" }, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + path: '/letters', + queryStringParameters: { limit: '-1' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -149,15 +151,19 @@ describe('API Lambda handler', () => { const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); - it("returns 400 if the limit parameter is zero", async () => { + it('returns error if the limit parameter is zero', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { limit: "0" }, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + path: '/letters', + queryStringParameters: { limit: '0' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -166,15 +172,19 @@ describe('API Lambda handler', () => { const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); - it("returns 400 if the limit parameter is higher than max limit", async () => { + it('returns error if the limit parameter is higher than max limit', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { limit: "2501" }, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + path: '/letters', + queryStringParameters: { limit: '2501' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -183,15 +193,19 @@ describe('API Lambda handler', () => { const result = await getLettersHandler(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith( - new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [getMaxLimit().maxLimit] }), 'correlationId', mockedDeps.logger); + new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); - it("returns 400 if unknown parameters are present", async () => { + it('returns error if unknown parameters are present', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { max: "2000" }, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + path: '/letters', + queryStringParameters: { max: '2000' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -203,8 +217,8 @@ describe('API Lambda handler', () => { expect(result).toEqual(expectedErrorResponse); }); - it('returns 400 for missing supplier ID (empty headers)', async () => { - const event = makeApiGwEvent({ path: "/letters", headers: {} }); + it('returns error if headers are empty', async () => { + const event = makeApiGwEvent({ path: '/letters', headers: {} }); const context = mockDeep(); const callback = jest.fn(); @@ -215,11 +229,14 @@ describe('API Lambda handler', () => { expect(result).toEqual(expectedErrorResponse); }); - it("returns 500 if correlation id not provided in request", async () => { + it('returns error if correlation id not provided in request', async () => { const event = makeApiGwEvent({ - path: "/letters", - queryStringParameters: { limit: "2000" }, - headers: {'nhsd-supplier-id': 'supplier1'} + path: '/letters', + queryStringParameters: { limit: '2000' }, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -230,4 +247,28 @@ describe('API Lambda handler', () => { expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); + + it('returns error if max limit is not set', async () => { + const event = makeApiGwEvent({path: '/letters', + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const mockedDepsNoMaxLimit = { + ...mockedDeps, + env: { ...mockedDeps.env }, + }; + delete mockedDepsNoMaxLimit.env.MAX_LIMIT; + + const getLettersHandler = createGetLettersHandler(mockedDepsNoMaxLimit); + const result = await getLettersHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('MAX_LIMIT is required for getLetters'), 'correlationId', mockedDepsNoMaxLimit.logger); + expect(result).toEqual(expectedErrorResponse); + }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index 0ec1954b..8c96ce79 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -47,24 +47,28 @@ describe('patchLetter API Handler', () => { }); const mockedDeps: jest.Mocked = { - s3Client: {} as unknown as S3Client, - letterRepo: {} as unknown as LetterRepository, - logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, - env: { - SUPPLIER_ID_HEADER: 'nhsd-supplier-id', - APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', - LETTER_TTL_HOURS: 1, - DOWNLOAD_URL_TTL_SECONDS: 1 - } as unknown as EnvVars -} + s3Client: {} as unknown as S3Client, + letterRepo: {} as unknown as LetterRepository, + logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger, + env: { + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME', + LETTER_TTL_HOURS: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60 + } as unknown as EnvVars + } it('returns 200 OK with updated resource', async () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -97,7 +101,11 @@ describe('patchLetter API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -113,7 +121,11 @@ describe('patchLetter API Handler', () => { const event = makeApiGwEvent({ path: '/letters/', body: requestBody, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -133,7 +145,11 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: requestBody, pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -150,7 +166,10 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: requestBody, pathParameters: {id: 'id1'}, - headers: {'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -158,7 +177,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId', mockedDeps.logger); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The supplier ID is missing from the request'), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -167,7 +186,11 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: "{test: 'test'}", pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -184,7 +207,11 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: '{#invalidJSON', pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -201,7 +228,11 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: 'somebody', pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -225,7 +256,10 @@ describe('patchLetter API Handler', () => { path: '/letters/id1', body: requestBody, pathParameters: {id: 'id1'}, - headers: {'nhsd-supplier-id': 'supplier1'} + headers: { + 'nhsd-supplier-id': 'supplier1', + 'x-request-id': 'requestId' + } }); const context = mockDeep(); const callback = jest.fn(); @@ -237,7 +271,7 @@ describe('patchLetter API Handler', () => { expect(result).toEqual(expectedErrorResponse); }); - it('returns 400 for missing supplier ID (empty headers)', async () => { + it('returns error if headers are empty', async () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, @@ -253,4 +287,24 @@ describe('patchLetter API Handler', () => { expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); + + it('returns error when request id is missing', async () => { + const event = makeApiGwEvent({ + path: '/letters/id1', + body: requestBody, + pathParameters: {id: 'id1'}, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the x-request-id"), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); }); diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 6769d3bd..0d42ea20 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,19 +1,16 @@ -import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; -import { getLettersForSupplier } from "../services/letter-operations"; -import { validateCommonHeaders } from "../utils/validation"; +import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from 'aws-lambda'; +import { getLettersForSupplier } from '../services/letter-operations'; +import { validateCommonHeaders } from '../utils/validation'; import { ApiErrorDetail } from '../contracts/errors'; -import { mapErrorToResponse } from "../mappers/error-mapper"; -import { ValidationError } from "../errors"; -import { mapToGetLettersResponse } from "../mappers/letter-mapper"; -import type { Deps } from "../config/deps"; +import { mapErrorToResponse } from '../mappers/error-mapper'; +import { ValidationError } from '../errors'; +import { mapToGetLettersResponse } from '../mappers/letter-mapper'; +import type { Deps } from '../config/deps'; import { Logger } from 'pino'; -export const getMaxLimit = (): { maxLimit: number } => ({ - maxLimit: parseInt(process.env.MAX_LIMIT!) -}); // The endpoint should only return pending letters for now -const status = "PENDING"; +const status = 'PENDING'; export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { @@ -25,9 +22,9 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } - const { maxLimit } = getMaxLimit(); - try { + const maxLimit = getMaxLimit(deps); + const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit, deps.logger); const letters = await getLettersForSupplier( @@ -68,11 +65,11 @@ function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQuery if ( queryStringParameters && Object.keys(queryStringParameters).some( - (key) => key !== "limit" + (key) => key !== 'limit' ) ) { logger.info({ - description: "Unexpected query parameter(s) present", + description: 'Unexpected query parameter(s) present', queryStringParameters: queryStringParameters, }); throw new ValidationError(ApiErrorDetail.InvalidRequestLimitOnly); @@ -95,7 +92,7 @@ function getLimit(limit: string | undefined, maxLimit: number, logger: Logger) { function assertIsNumber(limitNumber: number, logger: Logger) { if (isNaN(limitNumber)) { logger.info({ - description: "limit parameter is not a number", + description: 'limit parameter is not a number', limitNumber, }); throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotANumber); @@ -105,9 +102,18 @@ function assertIsNumber(limitNumber: number, logger: Logger) { function assertLimitInRange(limitNumber: number, maxLimit: number, logger: Logger) { if (limitNumber <= 0 || limitNumber > maxLimit) { logger.info({ - description: "Limit value is invalid", + description: 'Limit value is invalid', limitNumber, }); throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [maxLimit]}); } } + +function getMaxLimit(deps: Deps): number{ + + if (deps.env.MAX_LIMIT == null) { + throw new Error('MAX_LIMIT is required for getLetters'); + } + + return deps.env.MAX_LIMIT; +} diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index c6c3ace9..b8338e84 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -128,10 +128,10 @@ describe('getLetterDataUrl function', () => { const logger = jest.fn() as unknown as pino.Logger;; const env = { LETTERS_TABLE_NAME: 'LettersTable', - LETTER_TTL_HOURS: 24, + LETTER_TTL_HOURS: 12960, SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id', - DOWNLOAD_URL_TTL_SECONDS: 3600 + DOWNLOAD_URL_TTL_SECONDS: 60 }; const deps: Deps = { s3Client, letterRepo, logger, env }; @@ -145,7 +145,7 @@ describe('getLetterDataUrl function', () => { Bucket: 'letterDataBucket', Key: 'letter1.pdf' }; - expect(mockedGetSignedUrl).toHaveBeenCalledWith(s3Client, { input: expectedCommandInput}, { expiresIn: 3600}); + expect(mockedGetSignedUrl).toHaveBeenCalledWith(s3Client, { input: expectedCommandInput}, { expiresIn: 60}); expect(result).toEqual('http://somePreSignedUrl.com'); }); diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index 7d1e0e2e..318131c4 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -1,10 +1,7 @@ -import { APIGatewayProxyEventHeaders } from "aws-lambda"; -import { EnvVars } from "../config/env"; -import { ValidationError } from "../errors"; -import { ApiErrorDetail } from "../contracts/errors"; -import { mapErrorToResponse } from "../mappers/error-mapper"; -import { getLetterDataUrl } from "../services/letter-operations"; -import { Deps } from "../config/deps"; +import { APIGatewayProxyEventHeaders } from 'aws-lambda'; +import { ValidationError } from '../errors'; +import { ApiErrorDetail } from '../contracts/errors'; +import { Deps } from '../config/deps'; export function assertNotEmpty( value: T | null | undefined, @@ -14,11 +11,11 @@ export function assertNotEmpty( throw error; } - if (typeof value === "string" && value.trim() === "") { + if (typeof value === 'string' && value.trim() === '') { throw error; } - if (typeof value === "object" && Object.keys(value).length === 0) { + if (typeof value === 'object' && Object.keys(value).length === 0) { throw error; } @@ -33,7 +30,7 @@ export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps ): { ok: true; value: {correlationId: string, supplierId: string } } | { ok: false; error: Error; correlationId?: string } { if (!headers || Object.keys(headers).length === 0) { - return { ok: false, error: new Error("The request headers are empty") }; + return { ok: false, error: new Error('The request headers are empty') }; } const lowerCasedHeaders = lowerCaseKeys(headers); @@ -43,12 +40,21 @@ export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps return { ok: false, error: new Error("The request headers don't contain the APIM correlation id") }; } + const requestId = lowerCasedHeaders['x-request-id']; + if (!requestId) { + return { + ok: false, + error: new Error("The request headers don't contain the x-request-id"), + correlationId + }; + } + const supplierId = lowerCasedHeaders[deps.env.SUPPLIER_ID_HEADER]; if (!supplierId) { return { ok: false, - error: new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId), - correlationId, + error: new Error('The supplier ID is missing from the request'), + correlationId }; }