diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index fd16d4e6..c05256f5 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -32,12 +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\_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/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 982bc110..c094c52f 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_letter_data.function_arn PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn }) @@ -16,8 +17,9 @@ 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" + 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 = 60 } } 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 new file mode 100644 index 00000000..5bf0597d --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_get_letter_data.tf @@ -0,0 +1,78 @@ +module "get_letter_data" { + 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" + + 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:GetItem", + "dynamodb:Query" + ] + + resources = [ + aws_dynamodb_table.letters.arn, + "${aws_dynamodb_table.letters.arn}/index/supplierStatus-index" + ] + } + + statement { + sid = "S3GetObjectForPresign" + actions = [ + "s3:GetObject", + "s3:ListBucket"] # allows 404 response instead of 403 if object missing + resources = ["${module.s3bucket_test_letters.arn}/*"] + } +} 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" diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index ec58b67e..a2284fe9 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -105,6 +105,66 @@ "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" + } + }, + "security": [ + { + "LambdaAuthorizer": [] + } + ], + "summary": "Fetch a data file", + "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", + "in": "path", + "name": "id", + "required": true, + "schema": { + "example": "24L5eYSWGzCHlGmzNxuqVusPxDg", + "type": "string" + } + } + ] } } } 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/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts new file mode 100644 index 00000000..2028db88 --- /dev/null +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -0,0 +1,68 @@ + +import type { Deps } from '../deps'; + +describe('createDependenciesContainer', () => { + 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', () => ({ + envVars: { + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: 12960, + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: 60 + }, + })); + }); + + 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 { createDependenciesContainer } = require('../deps'); + const deps: Deps = createDependenciesContainer(); + + 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: 12960 + }); + + expect(deps.env).toEqual({ + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: 12960, + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + 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 new file mode 100644 index 00000000..c24205ab --- /dev/null +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -0,0 +1,63 @@ +import { ZodError } from 'zod'; + +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 = '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'; + process.env.MAX_LIMIT = '2500'; + + 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: 2500, + }); + }); + + it('should throw if a required env var is missing', () => { + 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 = '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/deps.ts b/lambdas/api-handler/src/config/deps.ts new file mode 100644 index 00000000..6f9c3e84 --- /dev/null +++ b/lambdas/api-handler/src/config/deps.ts @@ -0,0 +1,36 @@ +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 { envVars, EnvVars } from "../config/env"; + +export type Deps = { + s3Client: S3Client; + letterRepo: LetterRepository; + logger: pino.Logger, + env: EnvVars +}; + +function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository { + const ddbClient = new DynamoDBClient({}); + const docClient = DynamoDBDocumentClient.from(ddbClient); + const config = { + lettersTableName: envVars.LETTERS_TABLE_NAME, + ttlHours: envVars.LETTER_TTL_HOURS + }; + + return new LetterRepository(docClient, log, config); +} + +export function createDependenciesContainer(): Deps { + + const log = pino(); + + return { + s3Client: new S3Client(), + letterRepo: createLetterRepository(log, envVars), + logger: log, + env: envVars + }; +} diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts new file mode 100644 index 00000000..5dfe4d27 --- /dev/null +++ b/lambdas/api-handler/src/config/env.ts @@ -0,0 +1,14 @@ +import {z} from 'zod'; + +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(), + MAX_LIMIT: z.coerce.number().int().optional() +}); + +export type EnvVars = z.infer; + +export const envVars = EnvVarsSchema.parse(process.env); 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/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 new file mode 100644 index 00000000..5b6509a8 --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -0,0 +1,127 @@ +// mock error mapper +jest.mock('../../mappers/error-mapper'); +import { mapErrorToResponse } from '../../mappers/error-mapper'; +const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); +const expectedErrorResponse: APIGatewayProxyResult = { + statusCode: 400, + body: 'Error' +}; +mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); + +// mock letterService +jest.mock('../../services/letter-operations'); +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 { ValidationError } from '../../errors'; +import * as errors from '../../contracts/errors'; +import { createGetLetterDataHandler } from '../get-letter-data'; +import { S3Client } from '@aws-sdk/client-s3'; +import pino from 'pino'; +import { LetterRepository } from '../../../../../internal/datastore/src'; +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: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60 + } as unknown as EnvVars + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 303 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', + 'x-request-id': 'requestId' + }, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); + + expect(result).toEqual({ + statusCode: 303, + headers: { + 'Location': 'https://somePreSignedUrl.com', + }, + body: '' + }); + }); + + it('returns error if headers are empty', async () => { + const event = makeApiGwEvent({ path: '/letters/letter1/data', headers: {}, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); + + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + 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', + 'x-request-id': 'requestId' + }, + pathParameters: {id: 'id1'} + }); + const context = mockDeep(); + const callback = jest.fn(); + + 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, mockedDeps.logger); + 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', + 'x-request-id': 'requestId' + }, + }); + const context = mockDeep(); + const callback = jest.fn(); + + const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); + const result = await getLetterDataHandler(event, context, callback); + + 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 a1ef63a1..73b52896 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,6 @@ -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 error mapper jest.mock('../../mappers/error-mapper'); +import { mapErrorToResponse } from '../../mappers/error-mapper'; const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, @@ -16,32 +8,40 @@ 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 { 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 { EnvVars } from '../../config/env'; describe('API Lambda handler', () => { - const originalEnv = process.env; + 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: 12960, + DOWNLOAD_URL_TTL_SECONDS: 60, + MAX_LIMIT: 2500 + } as unknown as EnvVars + } 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(getEnvars().maxLimit).toBe(2500); }); it('returns 200 OK with basic paginated resources', async () => { @@ -49,49 +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 result = await getLetters(event, context, callback); + + 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' } } ], }; @@ -102,102 +112,163 @@ 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(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId'); + 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); }); - 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(); - 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: [getEnvars().maxLimit] }), 'correlationId'); + 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(); - 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: [getEnvars().maxLimit] }), 'correlationId'); + 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(); - 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: [getEnvars().maxLimit] }), 'correlationId'); + 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(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId'); + 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); }); - 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(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); + 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); }); - 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(); - const result = await getLetters(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); + 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); + }); + + 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 83f122ec..8c96ce79 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,11 @@ -import { patchLetter } from '../../index'; -import { APIGatewayProxyResult, Context } from 'aws-lambda'; -import { mockDeep } from 'jest-mock-extended'; -import { makeApiGwEvent } from './utils/test-utils'; +// 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 +13,18 @@ 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 { EnvVars } from '../../config/env'; +import { createPatchLetterHandler } from '../patch-letter'; +import { Deps } from "../../config/deps"; const updateLetterStatusRequest : PatchLetterRequest = { data: { @@ -41,18 +40,35 @@ 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: 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(); @@ -72,7 +88,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, @@ -84,14 +101,19 @@ 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(); - 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'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -99,13 +121,19 @@ 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(); - const result = await patchLetter(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); + 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); }); @@ -117,14 +145,19 @@ 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(); - const result = await patchLetter(event, context, callback); + const patchLetterHandler = createPatchLetterHandler(mockedDeps); + const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -133,14 +166,18 @@ 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(); - 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'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The supplier ID is missing from the request'), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -149,14 +186,19 @@ 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(); - 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'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -165,14 +207,19 @@ 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(); - 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'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -181,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(); @@ -191,9 +242,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'); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); spy.mockRestore(); @@ -204,18 +256,22 @@ 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(); - 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); + expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); 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, @@ -225,9 +281,30 @@ 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, 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 are empty'), undefined); + 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-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts new file mode 100644 index 00000000..547c8e17 --- /dev/null +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -0,0 +1,36 @@ +import { APIGatewayProxyHandler } from "aws-lambda"; +import { assertNotEmpty, validateCommonHeaders } from "../utils/validation"; +import { ApiErrorDetail } from '../contracts/errors'; +import { mapErrorToResponse } from "../mappers/error-mapper"; +import { ValidationError } from "../errors"; +import { getLetterDataUrl } from "../services/letter-operations"; +import type { Deps } from "../config/deps"; + + +export function createGetLetterDataHandler(deps: Deps): APIGatewayProxyHandler { + + return async (event) => { + + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } + + try { + const letterId = assertNotEmpty( event.pathParameters?.id, + new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); + + return { + statusCode: 303, + headers: { + 'Location': await getLetterDataUrl(commonHeadersResult.value.supplierId, letterId, deps) + }, + body: '' + }; + } + catch (error) { + 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 1f6f7df9..0d42ea20 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,114 +1,119 @@ -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 { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from 'aws-lambda'; +import { getLettersForSupplier } from '../services/letter-operations'; +import { validateCommonHeaders } 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 { 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'; -const letterRepo = createLetterRepository(); - -const log = pino(); - -export const getEnvars = (): { maxLimit: number } => ({ - maxLimit: parseInt(process.env.MAX_LIMIT!) -}); // The endpoint should only return pending letters for now -const status = "PENDING"; +const status = 'PENDING'; -export const getLetters: APIGatewayProxyHandler = async (event) => { +export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { - const { maxLimit } = getEnvars(); - let correlationId; + return 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 limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit); + const commonHeadersResult = validateCommonHeaders(event.headers, deps); - const letters = await getLettersForSupplier( - supplierId, - status, - limitNumber, - letterRepo, - ); + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } - const response = mapToGetLettersResponse(letters); + try { + const maxLimit = getMaxLimit(deps); - log.info({ - description: 'Pending letters successfully fetched', - supplierId, - limitNumber, - status, - lettersCount: letters.length - }); + const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit, deps.logger); - return { - statusCode: 200, - body: JSON.stringify(response, null, 2), - }; - } - catch (error) { - return mapErrorToResponse(error, correlationId); - } -}; + const letters = await getLettersForSupplier( + commonHeadersResult.value.supplierId, + status, + limitNumber, + deps.letterRepo, + ); -function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, maxLimit: number) : number { + const response = mapToGetLettersResponse(letters); - validateLimitParamOnly(queryStringParameters); - return getLimit(queryStringParameters?.limit, maxLimit); -} + deps.logger.info({ + description: 'Pending letters successfully fetched', + supplierId: commonHeadersResult.value.supplierId, + limitNumber, + status, + lettersCount: letters.length + }); -function assertIsNumber(limitNumber: number) { - if (isNaN(limitNumber)) { - log.info({ - description: "limit parameter is not a number", - limitNumber, - }); - throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotANumber); + return { + statusCode: 200, + body: JSON.stringify(response, null, 2), + }; + } + catch (error) { + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + } } -} +}; -function assertLimitInRange(limitNumber: number, maxLimit: number) { - if (limitNumber <= 0 || limitNumber > maxLimit) { - log.info({ - description: "Limit value is invalid", - limitNumber, - }); - throw new ValidationError(ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [maxLimit]}); - } +function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, maxLimit: number, logger: Logger) : number { + + 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" + (key) => key !== 'limit' ) ) { - log.info({ - description: "Unexpected query parameter(s) present", + logger.info({ + description: 'Unexpected query parameter(s) present', queryStringParameters: queryStringParameters, }); throw new ValidationError(ApiErrorDetail.InvalidRequestLimitOnly); } } -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]}); + } +} + +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/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index c381d5bf..e8442fae 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,46 +1,49 @@ 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 { assertNotEmpty, validateCommonHeaders } from '../utils/validation'; import { mapToLetterDto } from '../mappers/letter-mapper'; +import type { Deps } from "../config/deps"; -const letterRepo = createLetterRepository(); -export const patchLetter: APIGatewayProxyHandler = async (event) => { - let correlationId; +export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { - 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)); - const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); + return async (event) => { - let patchLetterRequest: PatchLetterRequest; + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } try { - patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); - } catch (error) { - if (error instanceof Error) { - throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); + 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, letterRepo); + const updatedLetter = await patchLetterStatus(mapToLetterDto(patchLetterRequest, commonHeadersResult.value.supplierId), letterId, deps.letterRepo); - return { - statusCode: 200, - body: JSON.stringify(result, null, 2) - }; + return { + statusCode: 200, + body: JSON.stringify(updatedLetter, null, 2) + }; - } catch (error) { - return mapErrorToResponse(error, correlationId); - } + } catch (error) { + return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + } + }; }; diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 203102f7..49a14008 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -1,3 +1,10 @@ -// Export all handlers for ease of access -export { getLetters } from './handlers/get-letters'; -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/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 7420c311..b8338e84 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,27 +1,28 @@ -import { Letter } from '../../../../../internal/datastore/src'; -import { LetterDto, LetterStatus } from '../../contracts/letters'; -import { getLettersForSupplier, patchLetterStatus } from '../letter-operations'; - - -function makeLetter(id: string, status: Letter['status']) : Letter { +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'; + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(), +})); +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +jest.mock('@aws-sdk/client-s3', () => { + const originalModule = jest.requireActual('@aws-sdk/client-s3'); return { - id, - status, - supplierId: 'supplier1', - specificationId: 'spec123', - groupId: 'group123', - url: 'https://example.com/letter/abc123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - supplierStatus: `supplier1#${status}`, - supplierStatusSk: Date.now().toString(), - ttl: 123, - reasonCode: 123, - reasonText: "Reason text" + GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })), }; -} +}); +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; describe("getLetterIdsForSupplier", () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + it("returns letter IDs from the repository", async () => { const mockRepo = { getLettersBySupplier: jest.fn().mockResolvedValue([ @@ -51,6 +52,10 @@ describe("getLetterIdsForSupplier", () => { describe('patchLetterStatus function', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const updatedLetterDto: LetterDto = { id: 'letter1', supplierId: 'supplier1', @@ -104,3 +109,78 @@ describe('patchLetterStatus function', () => { await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); }); }); + +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; + const letterRepo = { + getLetterById: jest.fn().mockResolvedValue(updatedLetter) + } as unknown as LetterRepository; + const logger = jest.fn() as unknown as pino.Logger;; + const env = { + LETTERS_TABLE_NAME: 'LettersTable', + LETTER_TTL_HOURS: 12960, + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', + APIM_CORRELATION_HEADER: 'nhsd-correlation-id', + DOWNLOAD_URL_TTL_SECONDS: 60 + }; + const deps: Deps = { s3Client, letterRepo, logger, env }; + + it('should return pre signed url successfully', async () => { + + mockedGetSignedUrl.mockResolvedValue('http://somePreSignedUrl.com'); + + const result = await getLetterDataUrl('supplier1', 'letter1', deps); + + const expectedCommandInput = { + Bucket: 'letterDataBucket', + Key: 'letter1.pdf' + }; + expect(mockedGetSignedUrl).toHaveBeenCalledWith(s3Client, { input: expectedCommandInput}, { expiresIn: 60}); + expect(result).toEqual('http://somePreSignedUrl.com'); + }); + + it('should throw notFoundError when letter does not exist', async () => { + 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', deps)).rejects.toThrow("No resource found with that ID"); + }); + + it('should throw unexpected error', async () => { + + deps.letterRepo = { + getLetterById: jest.fn().mockRejectedValue(new Error('unexpected error')) + } as unknown as LetterRepository; + + 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 4344ef95..f64b72f9 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -3,6 +3,9 @@ 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 { Deps } from '../config/deps'; export const getLettersForSupplier = async (supplierId: string, status: string, limit: number, letterRepo: LetterRepository): Promise => { @@ -29,3 +32,32 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str return mapToPatchLetterResponse(updatedLetter); } + +export const getLetterDataUrl = async (supplierId: string, letterId: string, deps: Deps): Promise => { + + let letter; + + try { + letter = await deps.letterRepo.getLetterById(supplierId, letterId); + 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); + } + throw error; + } +} + +async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: number) { + + 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(s3Client, command, { expiresIn: expiry }); +} diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index 4bb50960..318131c4 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -1,3 +1,8 @@ +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, error: Error @@ -6,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; } @@ -20,3 +25,38 @@ 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 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 Error('The supplier ID is missing from the request'), + correlationId + }; + } + + return { ok: true, value: { correlationId, supplierId } }; +} 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"