diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 1392cc338..2f28d7fbd 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -38,8 +38,11 @@ No requirements. | [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 | +| [letter\_status\_update](#module\_letter\_status\_update) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | +| [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.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.24/terraform-lambda.zip | n/a | +| [post\_letters](#module\_post\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a | | [post\_mi](#module\_post\_mi) | 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 | diff --git a/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf new file mode 100644 index 000000000..62d99edd9 --- /dev/null +++ b/infrastructure/terraform/components/api/event_source_mapping_status_updates_to_handler.tf @@ -0,0 +1,12 @@ +resource "aws_lambda_event_source_mapping" "status_updates_sqs_to_status_update_handler" { + event_source_arn = module.letter_status_updates_queue.sqs_queue_arn + function_name = module.letter_status_update.function_arn + batch_size = 10 + maximum_batching_window_in_seconds = 1 + scaling_config { maximum_concurrency = 10 } + + depends_on = [ + module.letter_status_updates_queue, # ensures queue exists + module.letter_status_update # ensures update handler exists + ] +} 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 2e90b260e..d6ff0cb43 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 @@ -53,6 +53,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.get_letter_data.function_arn, module.get_letters.function_arn, module.patch_letter.function_arn, + module.post_letters.function_arn, module.post_mi.function_arn ] } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 513e8da1b..a6621a3d8 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -5,14 +5,15 @@ locals { root_domain_nameservers = local.acct.route53_zone_nameservers["supplier-api"] openapi_spec = templatefile("${path.module}/resources/spec.tmpl.json", { - APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn - AWS_REGION = var.region - AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn - GET_LETTER_LAMBDA_ARN = module.get_letter.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 - POST_MI_LAMBDA_ARN = module.post_mi.function_arn + APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn + AWS_REGION = var.region + AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn + GET_LETTER_LAMBDA_ARN = module.get_letter.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 + POST_LETTERS_LAMBDA_ARN = module.post_letters.function_arn + POST_MI_LAMBDA_ARN = module.post_mi.function_arn }) destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" diff --git a/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf new file mode 100644 index 000000000..92c9d611d --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_letter_status_update.tf @@ -0,0 +1,86 @@ +module "letter_status_update" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + + function_name = "letter_status_update" + description = "Processes letter status updates" + + 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.letter_status_update.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 = "letterStatusUpdate" + 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" "letter_status_update" { + 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", + "dynamodb:UpdateItem", + ] + + resources = [ + aws_dynamodb_table.letters.arn, + ] + } + + statement { + sid = "AllowQueueAccess" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:ChangeMessageVisibility" + ] + + resources = [ + module.letter_status_updates_queue.sqs_queue_arn + ] + } +} diff --git a/infrastructure/terraform/components/api/module_lambda_post_letters.tf b/infrastructure/terraform/components/api/module_lambda_post_letters.tf new file mode 100644 index 000000000..fb127948c --- /dev/null +++ b/infrastructure/terraform/components/api/module_lambda_post_letters.tf @@ -0,0 +1,72 @@ +module "post_letters" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip" + + function_name = "post_letters" + description = "Receives and accepts collection of letters to update" + + 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.post_letters.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 = "postLetters" + 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, { + QUEUE_URL = module.letter_status_updates_queue.sqs_queue_url, + MAX_LIMIT = var.max_get_limit + }) +} + +data "aws_iam_policy_document" "post_letters" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, ## Requires shared kms module + ] + } + + statement { + sid = "AllowQueueAccess" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + ] + + resources = [ + module.letter_status_updates_queue.sqs_queue_arn + ] + } +} diff --git a/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf b/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf new file mode 100644 index 000000000..a604faaf5 --- /dev/null +++ b/infrastructure/terraform/components/api/module_sqs_letter_status_updates.tf @@ -0,0 +1,16 @@ +# Queue to transport update letter status messages +module "letter_status_updates_queue" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + + name = "letter_status_updates_queue" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + + sqs_kms_key_arn = module.kms.key_arn + + create_dlq = true +} diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 4514619b0..76ed2dd1f 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -51,6 +51,43 @@ "type": "AWS_PROXY", "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTERS_LAMBDA_ARN}/invocations" } + }, + "post": { + "description": "Update the status of a collection of letters.", + "operationId": "postLetters", + "requestBody": { + "required": true + }, + "responses": { + "202": { + "description": "Acknowledges letters will be updated" + }, + "400": { + "description": "Bad request, invalid input data" + }, + "500": { + "description": "Server error" + } + }, + "security": [ + { + "LambdaAuthorizer": [] + } + ], + "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/${POST_LETTERS_LAMBDA_ARN}/invocations" + } } }, "/letters/{id}": { diff --git a/internal/datastore/src/types.md b/internal/datastore/src/types.md index 832460726..cef0f1103 100644 --- a/internal/datastore/src/types.md +++ b/internal/datastore/src/types.md @@ -31,14 +31,16 @@ erDiagram erDiagram MI { string id - string supplierId - string specificationId - string groupId string lineItem + string timestamp number quantity + string specificationId + string groupId number stockRemaining + string supplierId string createdAt string updatedAt + number ttl "min: -9007199254740991, max: 9007199254740991" } ``` diff --git a/lambdas/api-handler/package.json b/lambdas/api-handler/package.json index d973c9d53..6c68e475c 100644 --- a/lambdas/api-handler/package.json +++ b/lambdas/api-handler/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@aws-sdk/client-sqs": "^3.925.0", "@internal/datastore": "*", "@internal/helpers": "*", "esbuild": "^0.25.11", diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts index 4381d9618..e56fe2c54 100644 --- a/lambdas/api-handler/src/config/__tests__/deps.test.ts +++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts @@ -32,6 +32,10 @@ describe('createDependenciesContainer', () => { S3Client: jest.fn(), })); + jest.mock('@aws-sdk/client-sqs', () => ({ + SQSClient: jest.fn(), + })); + // Repo client jest.mock('@internal/datastore', () => ({ LetterRepository: jest.fn(), @@ -45,6 +49,7 @@ describe('createDependenciesContainer', () => { 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 { SQSClient } = jest.requireMock('@aws-sdk/client-sqs') as { SQSClient: jest.Mock }; const pinoMock = jest.requireMock('pino') as { default: jest.Mock }; const { LetterRepository, MIRepository } = jest.requireMock('@internal/datastore') as { LetterRepository: jest.Mock, @@ -55,6 +60,9 @@ describe('createDependenciesContainer', () => { const deps: Deps = createDependenciesContainer(); expect(S3Client).toHaveBeenCalledTimes(1); + + expect(SQSClient).toHaveBeenCalledTimes(1); + expect(pinoMock.default).toHaveBeenCalledTimes(1); expect(LetterRepository).toHaveBeenCalledTimes(1); diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts index 2399c9418..acf82296d 100644 --- a/lambdas/api-handler/src/config/__tests__/env.test.ts +++ b/lambdas/api-handler/src/config/__tests__/env.test.ts @@ -21,6 +21,7 @@ describe('lambdaEnv', () => { process.env.MI_TTL_HOURS = '2160'; process.env.DOWNLOAD_URL_TTL_SECONDS = '60'; process.env.MAX_LIMIT = '2500'; + process.env.QUEUE_URL = 'url'; const { envVars } = require('../env'); @@ -33,6 +34,7 @@ describe('lambdaEnv', () => { MI_TTL_HOURS: 2160, DOWNLOAD_URL_TTL_SECONDS: 60, MAX_LIMIT: 2500, + QUEUE_URL: 'url' }); }); diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts index 1942bc6cd..58739aba8 100644 --- a/lambdas/api-handler/src/config/deps.ts +++ b/lambdas/api-handler/src/config/deps.ts @@ -1,12 +1,14 @@ import { S3Client } from "@aws-sdk/client-s3"; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { SQSClient } from "@aws-sdk/client-sqs"; import pino from 'pino'; import { LetterRepository, MIRepository } from '../../../../internal/datastore'; import { envVars, EnvVars } from "../config/env"; export type Deps = { s3Client: S3Client; + sqsClient: SQSClient; letterRepo: LetterRepository; miRepo: MIRepository; logger: pino.Logger; @@ -18,37 +20,35 @@ function createDocumentClient(): DynamoDBDocumentClient { return DynamoDBDocumentClient.from(ddbClient); } -function createLetterRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): LetterRepository { - const ddbClient = new DynamoDBClient({}); - const docClient = DynamoDBDocumentClient.from(ddbClient); +function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository { + const config = { lettersTableName: envVars.LETTERS_TABLE_NAME, lettersTtlHours: envVars.LETTER_TTL_HOURS }; - return new LetterRepository(docClient, log, config); + return new LetterRepository(createDocumentClient(), log, config); } -function createMIRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): MIRepository { - const ddbClient = new DynamoDBClient({}); - const docClient = DynamoDBDocumentClient.from(ddbClient); +function createMIRepository(log: pino.Logger, envVars: EnvVars): MIRepository { + const config = { miTableName: envVars.MI_TABLE_NAME, miTtlHours: envVars.MI_TTL_HOURS }; - return new MIRepository(docClient, log, config); + return new MIRepository(createDocumentClient(), log, config); } export function createDependenciesContainer(): Deps { const log = pino(); - const documentClient = createDocumentClient(); return { s3Client: new S3Client(), - letterRepo: createLetterRepository(documentClient, log, envVars), - miRepo: createMIRepository(documentClient, log, envVars), + sqsClient: new SQSClient(), + letterRepo: createLetterRepository(log, envVars), + miRepo: createMIRepository(log, envVars), logger: log, env: envVars }; diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts index f77f455ea..016f54d59 100644 --- a/lambdas/api-handler/src/config/env.ts +++ b/lambdas/api-handler/src/config/env.ts @@ -8,7 +8,8 @@ const EnvVarsSchema = z.object({ LETTER_TTL_HOURS: z.coerce.number().int(), MI_TTL_HOURS: z.coerce.number().int(), DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int(), - MAX_LIMIT: z.coerce.number().int().optional() + MAX_LIMIT: z.coerce.number().int().optional(), + QUEUE_URL: z.coerce.string().optional() }); export type EnvVars = z.infer; diff --git a/lambdas/api-handler/src/contracts/errors.ts b/lambdas/api-handler/src/contracts/errors.ts index a508739b3..44e54eacd 100644 --- a/lambdas/api-handler/src/contracts/errors.ts +++ b/lambdas/api-handler/src/contracts/errors.ts @@ -20,9 +20,9 @@ export enum ApiErrorTitle { } export enum ApiErrorStatus { - InternalServerError = "500", - InvalidRequest = "400", - NotFound = "404" + InternalServerError = '500', + InvalidRequest = '400', + NotFound = '404' } export enum ApiErrorDetail { @@ -35,7 +35,9 @@ export enum ApiErrorDetail { InvalidRequestLimitNotInRange = 'The limit parameter must be a positive number not greater than %s', InvalidRequestLimitOnly = "Only 'limit' query parameter is supported", InvalidRequestNoRequestId = 'The request does not contain a request id', - InvalidRequestTimestamp = 'Timestamps should be UTC date/times in ISO8601 format, with a Z suffix' + InvalidRequestTimestamp = 'Timestamps should be UTC date/times in ISO8601 format, with a Z suffix', + InvalidRequestLettersToUpdate = 'The request exceeds the maximum of %s items allowed for update', + InvalidRequestDuplicateLetterId = 'The request cannot include multiple letter objects with the same id' } export function buildApiError(params: { @@ -54,3 +56,7 @@ export function buildApiError(params: { detail: params.detail, }; } + +export interface ErrorResponse { + errors: ApiError[]; +} diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts index 1cc33a37d..6cab8c70e 100644 --- a/lambdas/api-handler/src/contracts/letters.ts +++ b/lambdas/api-handler/src/contracts/letters.ts @@ -1,16 +1,6 @@ import { z } from 'zod'; import { makeCollectionSchema, makeDocumentSchema } from './json-api'; -export type LetterDto = { - id: string, - status: LetterStatus, - supplierId: string, - specificationId?: string, - groupId?: string, - reasonCode?: string, - reasonText?: string -}; - export const LetterStatusSchema = z.enum([ 'PENDING', 'ACCEPTED', @@ -25,6 +15,18 @@ export const LetterStatusSchema = z.enum([ 'DELIVERED' ]); +export const LetterDtoSchema = z.object({ + id: z.string(), + status: LetterStatusSchema, + supplierId: z.string(), + specificationId: z.string().optional(), + groupId: z.string().optional(), + reasonCode: z.string().optional(), + reasonText: z.string().optional(), +}).strict(); + +export type LetterDto = z.infer; + export const PatchLetterRequestResourceSchema = z.object({ id: z.string(), type: z.literal('Letter'), @@ -62,11 +64,15 @@ export const PatchLetterResponseResourceSchema = GetLetterResponseResourceSchema export type LetterStatus = z.infer; export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema); +export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema); +export const PostLettersRequestSchema = makeCollectionSchema(PatchLetterRequestResourceSchema); export const GetLetterResponseSchema = makeDocumentSchema(GetLetterResponseResourceSchema); export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema); -export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema); + +export type PostLettersRequestResource = z.infer; export type PatchLetterRequest = z.infer; +export type PatchLetterResponse = z.infer; +export type PostLettersRequest = z.infer; export type GetLetterResponse = z.infer; export type GetLettersResponse = z.infer; -export type PatchLetterResponse = z.infer; diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts index 38c339edc..6a5cd7911 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts @@ -1,12 +1,12 @@ // mock error mapper jest.mock('../../mappers/error-mapper'); -import { mapErrorToResponse } from '../../mappers/error-mapper'; -const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); +import { processError } from '../../mappers/error-mapper'; +const mockedProcessError = jest.mocked(processError); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, body: 'Error' }; -mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); +mockedProcessError.mockReturnValue(expectedErrorResponse); // mock letterService jest.mock('../../services/letter-operations'); @@ -82,7 +82,7 @@ describe('API Lambda handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -102,7 +102,7 @@ describe('API Lambda handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -121,7 +121,7 @@ describe('API Lambda handler', () => { const getLetterDataHandler = createGetLetterDataHandler(mockedDeps); const result = await getLetterDataHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).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 581e3960a..caa660ffe 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -1,12 +1,12 @@ // mock error mapper jest.mock('../../mappers/error-mapper'); -import { mapErrorToResponse } from '../../mappers/error-mapper'; -const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); +import { processError } from '../../mappers/error-mapper'; +const mockedProcessError = jest.mocked(processError); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, body: 'Error' }; -mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); +mockedProcessError.mockReturnValue(expectedErrorResponse); //mock letter service jest.mock('../../services/letter-operations'); @@ -175,7 +175,7 @@ describe('API Lambda handler', () => { const result = await getLettersHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -195,7 +195,7 @@ describe('API Lambda handler', () => { const getLettersHandler = createGetLettersHandler(mockedDeps); const result = await getLettersHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith( + expect(mockedProcessError).toHaveBeenCalledWith( new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -216,7 +216,7 @@ describe('API Lambda handler', () => { const getLettersHandler = createGetLettersHandler(mockedDeps); const result = await getLettersHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith( + expect(mockedProcessError).toHaveBeenCalledWith( new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -237,7 +237,7 @@ describe('API Lambda handler', () => { const getLettersHandler = createGetLettersHandler(mockedDeps); const result = await getLettersHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith( + expect(mockedProcessError).toHaveBeenCalledWith( new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange, { args: [mockedDeps.env.MAX_LIMIT] }), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -258,7 +258,7 @@ describe('API Lambda handler', () => { const getLettersHandler = createGetLettersHandler(mockedDeps); const result = await getLettersHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -270,7 +270,7 @@ describe('API Lambda handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -289,7 +289,7 @@ describe('API Lambda handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -313,7 +313,7 @@ describe('API Lambda handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error('Missing required environment variable: MAX_LIMIT'), 'correlationId', mockedDepsNoMaxLimit.logger); expect(result).toEqual(expectedErrorResponse); }); }); diff --git a/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts new file mode 100644 index 000000000..b2730182a --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/letter-status-update.test.ts @@ -0,0 +1,131 @@ +import { Context, SQSEvent, SQSRecord } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { LetterDto } from '../../contracts/letters'; +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'; +import { createLetterStatusUpdateHandler } from '../letter-status-update'; + +describe('createLetterStatusUpdateHandler', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('processes letters successfully', async () => { + + const lettersToUpdate: LetterDto[] = [ + { + id: 'id1', + status: 'REJECTED', + supplierId: 's1', + specificationId: 'spec1', + groupId: 'g1', + reasonCode: '123', + reasonText: 'Reason text' + }, + { + id: 'id2', + supplierId: 's2', + status: 'ACCEPTED' + }, + { + id: 'id3', + supplierId: 's3', + status: 'DELIVERED' + } + ]; + + const mockedDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: { updateLetterStatus: jest.fn() + .mockResolvedValueOnce(lettersToUpdate[0]) + .mockResolvedValueOnce(lettersToUpdate[1]) + .mockResolvedValueOnce(lettersToUpdate[2]) } 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, + QUEUE_URL: 'SQS_URL' + } as unknown as EnvVars + } as Deps; + + const context = mockDeep(); + const callback = jest.fn(); + + const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); + await letterStatusUpdateHandler(buildEvent(lettersToUpdate), context, callback); + + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(1, lettersToUpdate[0]); + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(2, lettersToUpdate[1]); + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenNthCalledWith(3, lettersToUpdate[2]); + }); + + it('logs error if error thrown when updating', async () => { + + const mockError = new Error('Update error'); + + const mockedDeps: jest.Mocked = { + s3Client: {} as unknown as S3Client, + letterRepo: { updateLetterStatus: jest.fn().mockRejectedValue(mockError) } 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, + QUEUE_URL: 'SQS_URL' + } as unknown as EnvVars + } as Deps; + + const context = mockDeep(); + const callback = jest.fn(); + + const letterToUpdate: LetterDto[] = [{ + id: 'id1', + status: 'ACCEPTED', + supplierId: 's1' + }]; + + const letterStatusUpdateHandler = createLetterStatusUpdateHandler(mockedDeps); + await letterStatusUpdateHandler(buildEvent(letterToUpdate), context, callback); + + expect(mockedDeps.letterRepo.updateLetterStatus).toHaveBeenCalledWith(letterToUpdate[0]); + expect(mockedDeps.logger.error).toHaveBeenCalledWith({ + err: mockError, + messageId: 'mid-id1', + correlationId: 'correlationId-id1', + messageBody: '{\"id\":\"id1\",\"status\":\"ACCEPTED\",\"supplierId\":\"s1\"}', + }, 'Error processing letter status update'); + }); +}); + +function buildEvent(lettersToUpdate: LetterDto[]): SQSEvent { + + const records: Partial[] = lettersToUpdate.map(letter => { + return { + messageId: `mid-${letter.id}`, + body: JSON.stringify(letter), + messageAttributes: { + CorrelationId: { + dataType: 'String', + stringValue: `correlationId-${letter.id}`, + } + } + }; + }); + + const event: Partial = { + Records: records as SQSRecord[], + }; + + return event as SQSEvent; +} 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 6ec921116..dc573c5eb 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -5,13 +5,13 @@ const mockedPatchLetterStatus = jest.mocked(letterService.patchLetterStatus); // mock mapper jest.mock('../../mappers/error-mapper'); -import { mapErrorToResponse } from '../../mappers/error-mapper'; -const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); +import { processError } from '../../mappers/error-mapper'; +const mockedProcessError = jest.mocked(processError); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, body: 'Error' }; -mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); +mockedProcessError.mockReturnValue(expectedErrorResponse); import { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; @@ -113,7 +113,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -133,7 +133,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -157,7 +157,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -177,7 +177,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The supplier ID is missing from the request'), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new Error('The supplier ID is missing from the request'), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -198,7 +198,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -219,7 +219,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -245,7 +245,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); spy.mockRestore(); @@ -267,7 +267,7 @@ describe('patchLetter API Handler', () => { 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, mockedDeps.logger); + expect(mockedProcessError).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -284,7 +284,7 @@ describe('patchLetter API Handler', () => { 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(mockedProcessError).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined, mockedDeps.logger); expect(result).toEqual(expectedErrorResponse); }); @@ -304,7 +304,7 @@ describe('patchLetter API Handler', () => { const patchLetterHandler = createPatchLetterHandler(mockedDeps); const result = await patchLetterHandler(event, context, callback); - expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the x-request-id"), 'correlationId', mockedDeps.logger); + expect(mockedProcessError).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/__tests__/post-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/post-letters.test.ts new file mode 100644 index 000000000..bd4d19ea7 --- /dev/null +++ b/lambdas/api-handler/src/handlers/__tests__/post-letters.test.ts @@ -0,0 +1,296 @@ +// mock service +jest.mock('../../services/letter-operations'); +import * as letterService from '../../services/letter-operations'; +const mockedBatchUpdateStatus = jest.mocked(letterService.enqueueLetterUpdateRequests); + +// mock mapper +jest.mock('../../mappers/error-mapper'); +import { processError } from '../../mappers/error-mapper'; +const mockedProcessError = jest.mocked(processError); +const expectedErrorResponse: APIGatewayProxyResult = { + statusCode: 400, + body: 'Error' +}; +mockedProcessError.mockReturnValue(expectedErrorResponse); + +import { APIGatewayProxyResult, Context } from 'aws-lambda'; +import { mockDeep } from 'jest-mock-extended'; +import { makeApiGwEvent } from './utils/test-utils'; +import { PostLettersRequest } 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 { Deps } from "../../config/deps"; +import { createPostLettersHandler } from '../post-letters'; + +const updateLettersRequest : PostLettersRequest = { + data: [ + { + id: 'id1', + type: 'Letter', + attributes: { + status: 'REJECTED', + reasonCode: '123', + reasonText: 'Reason text', + } + }, + { + id: 'id2', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + }, + { + id: 'id3', + type: 'Letter', + attributes: { + status: 'DELIVERED' + } + }, + ] +}; + +const requestBody = JSON.stringify(updateLettersRequest, null, 2); + +describe('postLetters 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, + MAX_LIMIT: 2500, + QUEUE_URL: 'SQS_URL' + } as unknown as EnvVars + } as Deps; + + it('returns 202 Accepted', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: requestBody, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + mockedBatchUpdateStatus.mockResolvedValue(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(result).toEqual({ + statusCode: 202, + body: '' + }); + }); + + it('returns error when supplier id is missing', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: requestBody, + headers: { + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new Error('The supplier ID is missing from the request'), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error if correlation id not provided in request', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: requestBody, + headers: { + 'nhsd-supplier-id': 'supplier1', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).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 there is no body', 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 postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error when request body does not have correct shape', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: "{test: 'test'}", + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error when request contains more than 2500 letters to update', async () => { + + const event = makeApiGwEvent({ + path: '/letters', + body: JSON.stringify({ + data : Array.from({ length: 2501 }, + () => ({ + id: 'id1', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + }) + ) + }), + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLettersToUpdate, { args: [mockedDeps.env.MAX_LIMIT]}), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error when the request has duplicate letter ids', async () => { + + const event = makeApiGwEvent({ + path: '/letters', + body: JSON.stringify({ + data: [ + { + id: 'id1', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + }, + { + id: 'id1', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + } + ] + }), + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestDuplicateLetterId), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error when request body is not json', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: '{#invalidJSON', + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + }); + + it('returns error if unexpected error is thrown', async () => { + const event = makeApiGwEvent({ + path: '/letters', + body: 'somebody', + headers: { + 'nhsd-supplier-id': 'supplier1', + 'nhsd-correlation-id': 'correlationId', + 'x-request-id': 'requestId' + } + }); + const context = mockDeep(); + const callback = jest.fn(); + + const error = 'Unexpected error'; + const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => { + throw error; + }); + + const postLettersHandler = createPostLettersHandler(mockedDeps); + const result = await postLettersHandler(event, context, callback); + + expect(mockedProcessError).toHaveBeenCalledWith(error, 'correlationId', mockedDeps.logger); + expect(result).toEqual(expectedErrorResponse); + + spy.mockRestore(); + }); +}); diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 547c8e170..59c880839 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyHandler } from "aws-lambda"; import { assertNotEmpty, validateCommonHeaders } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; -import { mapErrorToResponse } from "../mappers/error-mapper"; +import { processError } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; import { getLetterDataUrl } from "../services/letter-operations"; import type { Deps } from "../config/deps"; @@ -14,7 +14,7 @@ export function createGetLetterDataHandler(deps: Deps): APIGatewayProxyHandler { const commonHeadersResult = validateCommonHeaders(event.headers, deps); if (!commonHeadersResult.ok) { - return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } try { @@ -30,7 +30,7 @@ export function createGetLetterDataHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + return processError(error, commonHeadersResult.value.correlationId, deps.logger); } } }; diff --git a/lambdas/api-handler/src/handlers/get-letter.ts b/lambdas/api-handler/src/handlers/get-letter.ts index 5c428dcef..0cbe3b7a5 100644 --- a/lambdas/api-handler/src/handlers/get-letter.ts +++ b/lambdas/api-handler/src/handlers/get-letter.ts @@ -3,7 +3,7 @@ import { assertNotEmpty, validateCommonHeaders } from "../utils/validation"; import { ValidationError } from "../errors"; import { ApiErrorDetail } from "../contracts/errors"; import { getLetterById } from "../services/letter-operations"; -import { mapErrorToResponse } from "../mappers/error-mapper"; +import { processError } from "../mappers/error-mapper"; import { mapToGetLetterResponse } from "../mappers/letter-mapper"; import { Deps } from "../config/deps"; @@ -15,7 +15,7 @@ export function createGetLetterHandler(deps: Deps): APIGatewayProxyHandler { const commonHeadersResult = validateCommonHeaders(event.headers, deps); if (!commonHeadersResult.ok) { - return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } try { @@ -37,7 +37,7 @@ export function createGetLetterHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + return processError(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 0d42ea206..ca258def9 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,14 +1,13 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from 'aws-lambda'; import { getLettersForSupplier } from '../services/letter-operations'; -import { validateCommonHeaders } from '../utils/validation'; +import { requireEnvVar, validateCommonHeaders } from '../utils/validation'; import { ApiErrorDetail } from '../contracts/errors'; -import { mapErrorToResponse } from '../mappers/error-mapper'; +import { processError } from '../mappers/error-mapper'; import { ValidationError } from '../errors'; import { mapToGetLettersResponse } from '../mappers/letter-mapper'; import type { Deps } from '../config/deps'; import { Logger } from 'pino'; - // The endpoint should only return pending letters for now const status = 'PENDING'; @@ -19,11 +18,11 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { const commonHeadersResult = validateCommonHeaders(event.headers, deps); if (!commonHeadersResult.ok) { - return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } try { - const maxLimit = getMaxLimit(deps); + const maxLimit = requireEnvVar(deps.env, "MAX_LIMIT"); const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit, deps.logger); @@ -50,7 +49,7 @@ export function createGetLettersHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + return processError(error, commonHeadersResult.value.correlationId, deps.logger); } } }; @@ -108,12 +107,3 @@ function assertLimitInRange(limitNumber: number, maxLimit: number, logger: Logge 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/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts new file mode 100644 index 000000000..6e8e92414 --- /dev/null +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -0,0 +1,24 @@ +import { SQSEvent, SQSHandler, SQSRecord } from 'aws-lambda'; +import { LetterDto, LetterDtoSchema } from '../contracts/letters'; +import { Deps } from '../config/deps'; + +export function createLetterStatusUpdateHandler(deps: Deps): SQSHandler { + + return async ( event: SQSEvent ) => { + const tasks = event.Records.map( async (message) => { + try { + const letterToUpdate: LetterDto = LetterDtoSchema.parse(JSON.parse(message.body)); + await deps.letterRepo.updateLetterStatus(letterToUpdate); + } catch (error) { + deps.logger.error({ + err: error, + messageId: message.messageId, + correlationId: message.messageAttributes['CorrelationId'].stringValue, + messageBody: message.body + }, 'Error processing letter status update'); + } + }); + + await Promise.all(tasks); + } +} diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index e8442fae9..42b5161c8 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -3,9 +3,9 @@ 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 { processError } from '../mappers/error-mapper'; import { assertNotEmpty, validateCommonHeaders } from '../utils/validation'; -import { mapToLetterDto } from '../mappers/letter-mapper'; +import { mapPatchLetterToDto } from '../mappers/letter-mapper'; import type { Deps } from "../config/deps"; @@ -16,7 +16,7 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { const commonHeadersResult = validateCommonHeaders(event.headers, deps); if (!commonHeadersResult.ok) { - return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } try { @@ -35,7 +35,7 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { else throw error; } - const updatedLetter = await patchLetterStatus(mapToLetterDto(patchLetterRequest, commonHeadersResult.value.supplierId), letterId, deps.letterRepo); + const updatedLetter = await patchLetterStatus(mapPatchLetterToDto(patchLetterRequest, commonHeadersResult.value.supplierId), letterId, deps.letterRepo); return { statusCode: 200, @@ -43,7 +43,7 @@ export function createPatchLetterHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + return processError(error, commonHeadersResult.value.correlationId, deps.logger); } }; }; diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts new file mode 100644 index 000000000..8437e9385 --- /dev/null +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -0,0 +1,61 @@ +import { APIGatewayProxyHandler } from 'aws-lambda'; +import { enqueueLetterUpdateRequests } from '../services/letter-operations'; +import { PostLettersRequest, PostLettersRequestSchema } from '../contracts/letters'; +import { ApiErrorDetail } from '../contracts/errors'; +import { ValidationError } from '../errors'; +import { processError } from '../mappers/error-mapper'; +import { assertNotEmpty, requireEnvVar, validateCommonHeaders } from '../utils/validation'; +import type { Deps } from "../config/deps"; + +export function createPostLettersHandler(deps: Deps): APIGatewayProxyHandler { + + return async (event) => { + + const commonHeadersResult = validateCommonHeaders(event.headers, deps); + + if (!commonHeadersResult.ok) { + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + } + + const maxUpdateItems = requireEnvVar(deps.env, "MAX_LIMIT"); + requireEnvVar(deps.env, "QUEUE_URL"); + + try { + const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); + + let postLettersRequest: PostLettersRequest; + + try { + postLettersRequest = PostLettersRequestSchema.parse(JSON.parse(body)); + } catch (error) { + if (error instanceof Error) { + throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); + } + else throw error; + } + + if (postLettersRequest.data.length > maxUpdateItems) { + throw new ValidationError(ApiErrorDetail.InvalidRequestLettersToUpdate, { args: [maxUpdateItems]}); + } + + if( duplicateIdsExist(postLettersRequest) ) { + throw new ValidationError(ApiErrorDetail.InvalidRequestDuplicateLetterId); + } + + await enqueueLetterUpdateRequests(postLettersRequest, commonHeadersResult.value.supplierId, commonHeadersResult.value.correlationId, deps); + + return { + statusCode: 202, + body: '' + }; + + } catch (error) { + return processError(error, commonHeadersResult.value.correlationId, deps.logger); + } + }; +}; + +function duplicateIdsExist(postLettersRequest: PostLettersRequest) { + const ids = postLettersRequest.data.map(item => item.id); + return new Set(ids).size !== ids.length; +} diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts index 526f849be..926fea6ce 100644 --- a/lambdas/api-handler/src/handlers/post-mi.ts +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -2,7 +2,7 @@ import { APIGatewayProxyHandler } from "aws-lambda"; import { postMI as postMIOperation } from '../services/mi-operations'; import { ApiErrorDetail } from "../contracts/errors"; import { ValidationError } from "../errors"; -import { mapErrorToResponse } from "../mappers/error-mapper"; +import { processError } from "../mappers/error-mapper"; import { assertNotEmpty, validateCommonHeaders, validateIso8601Timestamp } from "../utils/validation"; import { PostMIRequest, PostMIRequestSchema } from "../contracts/mi"; import { mapToMI } from "../mappers/mi-mapper"; @@ -15,7 +15,7 @@ export function createPostMIHandler(deps: Deps): APIGatewayProxyHandler { const commonHeadersResult = validateCommonHeaders(event.headers, deps); if (!commonHeadersResult.ok) { - return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); + return processError(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger); } try { @@ -41,7 +41,7 @@ export function createPostMIHandler(deps: Deps): APIGatewayProxyHandler { }; } catch (error) { - return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger); + return processError(error, commonHeadersResult.value.correlationId, deps.logger); } } }; diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 38328703e..010d44b1b 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -3,6 +3,8 @@ import { createGetLetterHandler } from "./handlers/get-letter"; import { createGetLetterDataHandler } from "./handlers/get-letter-data"; import { createGetLettersHandler } from "./handlers/get-letters"; import { createPatchLetterHandler } from "./handlers/patch-letter"; +import { createPostLettersHandler } from "./handlers/post-letters"; +import { createLetterStatusUpdateHandler } from "./handlers/letter-status-update"; import { createPostMIHandler } from "./handlers/post-mi"; const container = createDependenciesContainer(); @@ -11,4 +13,7 @@ export const getLetter = createGetLetterHandler(container); export const getLetterData = createGetLetterDataHandler(container); export const getLetters = createGetLettersHandler(container); export const patchLetter = createPatchLetterHandler(container); +export const letterStatusUpdate = createLetterStatusUpdateHandler(container); +export const postLetters = createPostLettersHandler(container); + export const postMI = createPostMIHandler(container); 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 5110e32bc..8a2d2c772 100644 --- a/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/error-mapper.test.ts @@ -1,13 +1,13 @@ -import { mapErrorToResponse } from "../error-mapper"; +import { processError } from "../error-mapper"; import { ValidationError, NotFoundError } from "../../errors"; import { ApiErrorDetail } from "../../contracts/errors"; import { Logger } from 'pino'; -describe("mapErrorToResponse", () => { +describe("processError", () => { it("should map ValidationError to InvalidRequest response", () => { const err = new ValidationError(ApiErrorDetail.InvalidRequestLetterIdsMismatch); - const res = mapErrorToResponse(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); + const res = processError(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(400); expect(JSON.parse(res.body)).toEqual({ @@ -29,7 +29,7 @@ describe("mapErrorToResponse", () => { it("should map NotFoundError to NotFound response", () => { const err = new NotFoundError(ApiErrorDetail.NotFoundLetterId); - const res = mapErrorToResponse(err, undefined, { info: jest.fn(), error: jest.fn() } as unknown as Logger); + const res = processError(err, undefined, { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(404); expect(JSON.parse(res.body)).toEqual({ @@ -51,7 +51,7 @@ describe("mapErrorToResponse", () => { it("should map generic Error to InternalServerError response", () => { const err = new Error("Something broke"); - const res = mapErrorToResponse(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); + const res = processError(err, 'correlationId', { info: jest.fn(), error: jest.fn() } as unknown as Logger); expect(res.statusCode).toEqual(500); expect(JSON.parse(res.body)).toEqual({ @@ -73,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', { info: jest.fn(), error: jest.fn() } as unknown as Logger); + const res = processError(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 99493234b..180e0c8f9 100644 --- a/lambdas/api-handler/src/mappers/error-mapper.ts +++ b/lambdas/api-handler/src/mappers/error-mapper.ts @@ -1,42 +1,47 @@ import { APIGatewayProxyResult } from "aws-lambda"; import { NotFoundError, ValidationError } from "../errors"; -import { buildApiError, ApiErrorCode, ApiErrorDetail, ApiErrorTitle, ApiError, ApiErrorStatus } from "../contracts/errors"; +import { buildApiError, ApiErrorCode, ApiErrorTitle, ApiError, ApiErrorStatus, ErrorResponse } from "../contracts/errors"; import { v4 as uuid } from 'uuid'; import { Logger } from "pino"; -export interface ErrorResponse { - errors: ApiError[]; +export function processError(error: unknown, correlationId: string | undefined, logger: Logger): APIGatewayProxyResult +{ + return mapToErrorResponse(logAndMapToApiError(error, correlationId, logger)); } -export function mapErrorToResponse(error: unknown, correlationId: string | undefined, logger: Logger): APIGatewayProxyResult { +export function logAndMapToApiError(error: unknown, correlationId: string | undefined, logger: Logger): ApiError { if (error instanceof ValidationError) { logger.info({ err: error }, `Validation error correlationId=${correlationId}`); - return buildResponseFromErrorCode(ApiErrorCode.InvalidRequest, error.detail, correlationId); + return mapToApiError(ApiErrorCode.InvalidRequest, error.detail, correlationId); } else if (error instanceof NotFoundError) { logger.info({ err: error }, `Not found error correlationId=${correlationId}`); - return buildResponseFromErrorCode(ApiErrorCode.NotFound, error.detail, correlationId); + return mapToApiError(ApiErrorCode.NotFound, error.detail, correlationId); } else if (error instanceof Error) { logger.error({ err: error }, `Internal server error correlationId=${correlationId}`); - return buildResponseFromErrorCode(ApiErrorCode.InternalServerError, error.message, correlationId); + return mapToApiError(ApiErrorCode.InternalServerError, error.message, correlationId); } else { logger.error({ err: error }, `Internal server error (non-Error thrown) correlationId=${correlationId}`); - return buildResponseFromErrorCode(ApiErrorCode.InternalServerError, "Unexpected error", correlationId); + return mapToApiError(ApiErrorCode.InternalServerError, "Unexpected error", correlationId); } } -function buildResponseFromErrorCode(code: ApiErrorCode, detail: string, correlationId: string | undefined): APIGatewayProxyResult { +function mapToErrorResponse(apiError: ApiError) +{ + return { + statusCode: +apiError.status, + body: JSON.stringify(buildErrorResponseFromError(apiError), null, 2) + }; +} + +function mapToApiError(code: ApiErrorCode, detail: string, correlationId: string | undefined): ApiError { const id = correlationId ? correlationId : uuid(); - const responseError = buildApiError({ + return buildApiError({ id, code, status: codeToStatus(code), title: codeToTitle(code), detail }); - return { - statusCode: +responseError.status, - body: JSON.stringify(buildErrorResponseFromError(responseError), null, 2) - }; } function codeToStatus(code: ApiErrorCode): ApiErrorStatus { diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index e0f73b933..7d6efd734 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,7 +1,7 @@ import { LetterBase, LetterStatus } from "@internal/datastore"; -import { GetLetterResponse, GetLetterResponseSchema, GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters'; +import { GetLetterResponse, GetLetterResponseSchema, GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema, PostLettersRequest, PostLettersRequestResource } from '../contracts/letters'; -export function mapToLetterDto(request: PatchLetterRequest, supplierId: string): LetterDto { +export function mapPatchLetterToDto(request: PatchLetterRequest, supplierId: string): LetterDto { return { id: request.data.id, supplierId, @@ -11,6 +11,16 @@ export function mapToLetterDto(request: PatchLetterRequest, supplierId: string): }; } +export function mapPostLetterResourceToDto(request: PostLettersRequestResource, supplierId: string): LetterDto { + return { + id: request.id, + supplierId, + status: LetterStatus.parse(request.attributes.status), + reasonCode: request.attributes.reasonCode, + reasonText: request.attributes.reasonText, + }; +} + export function mapToPatchLetterResponse(letter: LetterBase): PatchLetterResponse { return PatchLetterResponseSchema.parse({ data: letterToResourceResponse(letter) 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 d8dd0a35c..5c4c47cdf 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,7 +1,7 @@ import { Letter, LetterRepository } from '@internal/datastore'; import { Deps } from '../../config/deps'; -import { LetterDto } from '../../contracts/letters'; -import { getLetterById, getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations'; +import { LetterDto, PostLettersRequest } from '../../contracts/letters'; +import { enqueueLetterUpdateRequests, getLetterById, getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations'; import pino from 'pino'; jest.mock('@aws-sdk/s3-request-presigner', () => ({ @@ -16,6 +16,7 @@ jest.mock('@aws-sdk/client-s3', () => { }; }); import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs'; describe("getLetterIdsForSupplier", () => { @@ -227,3 +228,142 @@ function makeLetter(id: string, status: Letter['status']) : Letter { reasonText: "Reason text" }; } + +describe('enqueueLetterUpdateRequests function', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update the letter status successfully', async () => { + + const updateLettersRequest : PostLettersRequest = { + data: [ + { + id: 'id1', + type: 'Letter', + attributes: { + status: 'REJECTED', + reasonCode: 123, + reasonText: 'Reason text', + } + }, + { + id: 'id2', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + } + ] + }; + const sqsClient = { send: jest.fn() } as unknown as SQSClient; + const logger = { error: jest.fn() } as unknown as pino.Logger; + const env = { + QUEUE_URL: 'sqsUrl' + }; + const deps: Deps = { sqsClient, logger, env } as Deps; + + const result = await enqueueLetterUpdateRequests(updateLettersRequest, 'supplier1', 'correlationId1', deps); + + expect(result).toBeUndefined(); + + expect(deps.sqsClient.send).toHaveBeenNthCalledWith(1, expect.objectContaining({ + input: { + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { + DataType: 'String', + StringValue: 'correlationId1', + } + }, + MessageBody: JSON.stringify({ + id: updateLettersRequest.data[0].id, + supplierId: 'supplier1', + status: updateLettersRequest.data[0].attributes.status, + reasonCode: updateLettersRequest.data[0].attributes.reasonCode, + reasonText: updateLettersRequest.data[0].attributes.reasonText + }) + } + })); + + expect(deps.sqsClient.send).toHaveBeenNthCalledWith(2, expect.objectContaining({ + input: { + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { + DataType: 'String', + StringValue: 'correlationId1', + } + }, + MessageBody: JSON.stringify({ + id: updateLettersRequest.data[1].id, + supplierId: 'supplier1', + status: updateLettersRequest.data[1].attributes.status + }) + } + })); + }); + + it('should log error if enqueueing fails', async () => { + + const mockError = new Error('error'); + + const updateLettersRequest : PostLettersRequest = { + data: [ + { + id: 'id1', + type: 'Letter', + attributes: { + status: 'ACCEPTED' + } + }, + { + id: 'id2', + type: 'Letter', + attributes: { + status: 'REJECTED' + } + } + ] + }; + const sqsClient = { send: jest.fn() + .mockRejectedValue(mockError) + .mockResolvedValueOnce({ MessageId: 'm1' }) + } as unknown as SQSClient; + const logger = { error: jest.fn() } as unknown as pino.Logger; + const env = { + QUEUE_URL: 'sqsUrl' + }; + const deps: Deps = { sqsClient, logger, env } as Deps; + + const result = await enqueueLetterUpdateRequests(updateLettersRequest, 'supplier1', 'correlationId1', deps); + + expect(result).toBeUndefined(); + + expect(deps.sqsClient.send).toHaveBeenNthCalledWith(2, expect.objectContaining({ + input: { + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { + DataType: 'String', + StringValue: 'correlationId1', + } + }, + MessageBody: JSON.stringify({ + id: updateLettersRequest.data[1].id, + supplierId: 'supplier1', + status: updateLettersRequest.data[1].attributes.status + }) + } + })); + + expect(deps.logger.error).toHaveBeenCalledWith({ + err: mockError, + correlationId: 'correlationId1', + letterId: 'id2', + letterStatus: 'REJECTED', + supplierId: 'supplier1' + }, 'Error enqueuing letter status update'); + }); +}); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index 035aba2aa..839a1f819 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -1,10 +1,11 @@ -import { LetterBase, LetterRepository } from '@internal/datastore' +import { LetterBase, LetterRepository } from '@internal/datastore'; import { NotFoundError, ValidationError } from '../errors'; -import { LetterDto, PatchLetterResponse } from '../contracts/letters'; -import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; +import { LetterDto, PatchLetterResponse, PostLettersRequest } from '../contracts/letters'; +import { mapPostLetterResourceToDto, 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 { SendMessageCommand } from "@aws-sdk/client-sqs"; import { Deps } from '../config/deps'; @@ -48,9 +49,6 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str return mapToPatchLetterResponse(updatedLetter); } -function isNotFoundError(error: any) { - return error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message); -} export const getLetterDataUrl = async (supplierId: string, letterId: string, deps: Deps): Promise => { @@ -60,7 +58,7 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, dep 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)) { + if (isNotFoundError(error)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); } throw error; @@ -80,3 +78,33 @@ async function getDownloadUrl(s3Uri: string, s3Client: S3Client, expiry: number) return await getSignedUrl(s3Client, command, { expiresIn: expiry }); } + +export async function enqueueLetterUpdateRequests(postLettersRequest: PostLettersRequest, supplierId: string, correlationId: string, deps: Deps) { + + const tasks = postLettersRequest.data.map(async (request) => { + try { + const command = new SendMessageCommand({ + QueueUrl: deps.env.QUEUE_URL, + MessageAttributes: { + CorrelationId: { DataType: 'String', StringValue: correlationId }, + }, + MessageBody: JSON.stringify(mapPostLetterResourceToDto(request, supplierId)), + }); + await deps.sqsClient.send(command); + } catch (err) { + deps.logger.error({ + err, + correlationId: correlationId, + letterId: request.id, + letterStatus: request.attributes.status, + supplierId: supplierId + }, 'Error enqueuing letter status update'); + } + }); + + await Promise.all(tasks); +} + +function isNotFoundError(error: any) { + return error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message); +} diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index f86b4fffe..57aebb458 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -2,6 +2,7 @@ import { APIGatewayProxyEventHeaders } from 'aws-lambda'; import { ValidationError } from '../errors'; import { ApiErrorDetail } from '../contracts/errors'; import { Deps } from '../config/deps'; +import { EnvVars } from '../config/env'; export function assertNotEmpty( value: T | null | undefined, @@ -84,3 +85,14 @@ export function validateIso8601Timestamp(timestamp: string) { throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp); } } + +export function requireEnvVar( + envs: EnvVars, + name: T +): NonNullable { + const value = envs[name]; + if (value === undefined || value === null || value === "") { + throw new Error(`Missing required environment variable: ${name}`); + } + return value as NonNullable; +} diff --git a/package-lock.json b/package-lock.json index 69f093794..92d1968af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2049,6 +2049,7 @@ "name": "nhs-notify-supplier-api-handler", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-sqs": "^3.925.0", "@internal/datastore": "*", "@internal/helpers": "*", "esbuild": "^0.25.11", @@ -4238,6 +4239,533 @@ ], "license": "MIT" }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.925.0.tgz", + "integrity": "sha512-4ts+M2M7b7qB2zRa1pVvqXGFKrKSjOsI6zRd+DqbHUz7MvSFY8qVm1joH2ByqSLdK1XMdD6cwDj7Nn6uWNWziQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.922.0", + "@aws-sdk/credential-provider-node": "3.925.0", + "@aws-sdk/middleware-host-header": "3.922.0", + "@aws-sdk/middleware-logger": "3.922.0", + "@aws-sdk/middleware-recursion-detection": "3.922.0", + "@aws-sdk/middleware-sdk-sqs": "3.922.0", + "@aws-sdk/middleware-user-agent": "3.922.0", + "@aws-sdk/region-config-resolver": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@aws-sdk/util-endpoints": "3.922.0", + "@aws-sdk/util-user-agent-browser": "3.922.0", + "@aws-sdk/util-user-agent-node": "3.922.0", + "@smithy/config-resolver": "^4.4.2", + "@smithy/core": "^3.17.2", + "@smithy/fetch-http-handler": "^5.3.5", + "@smithy/hash-node": "^4.2.4", + "@smithy/invalid-dependency": "^4.2.4", + "@smithy/md5-js": "^4.2.4", + "@smithy/middleware-content-length": "^4.2.4", + "@smithy/middleware-endpoint": "^4.3.6", + "@smithy/middleware-retry": "^4.4.6", + "@smithy/middleware-serde": "^4.2.4", + "@smithy/middleware-stack": "^4.2.4", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/node-http-handler": "^4.4.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.5", + "@smithy/util-defaults-mode-node": "^4.2.8", + "@smithy/util-endpoints": "^3.2.4", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-retry": "^4.2.4", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/client-sso": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.925.0.tgz", + "integrity": "sha512-ixC9CyXe/mBo1X+bzOxIIzsdBYzM+klWoHUYzwnPMrXhpDrMjj8D24R/FPqrDnhoYYXiyS4BApRLpeymsFJq2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.922.0", + "@aws-sdk/middleware-host-header": "3.922.0", + "@aws-sdk/middleware-logger": "3.922.0", + "@aws-sdk/middleware-recursion-detection": "3.922.0", + "@aws-sdk/middleware-user-agent": "3.922.0", + "@aws-sdk/region-config-resolver": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@aws-sdk/util-endpoints": "3.922.0", + "@aws-sdk/util-user-agent-browser": "3.922.0", + "@aws-sdk/util-user-agent-node": "3.922.0", + "@smithy/config-resolver": "^4.4.2", + "@smithy/core": "^3.17.2", + "@smithy/fetch-http-handler": "^5.3.5", + "@smithy/hash-node": "^4.2.4", + "@smithy/invalid-dependency": "^4.2.4", + "@smithy/middleware-content-length": "^4.2.4", + "@smithy/middleware-endpoint": "^4.3.6", + "@smithy/middleware-retry": "^4.4.6", + "@smithy/middleware-serde": "^4.2.4", + "@smithy/middleware-stack": "^4.2.4", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/node-http-handler": "^4.4.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.5", + "@smithy/util-defaults-mode-node": "^4.2.8", + "@smithy/util-endpoints": "^3.2.4", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-retry": "^4.2.4", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/core": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.922.0.tgz", + "integrity": "sha512-EvfP4cqJfpO3L2v5vkIlTkMesPtRwWlMfsaW6Tpfm7iYfBOuTi6jx60pMDMTyJNVfh6cGmXwh/kj1jQdR+w99Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@aws-sdk/xml-builder": "3.921.0", + "@smithy/core": "^3.17.2", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/signature-v4": "^5.3.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.922.0.tgz", + "integrity": "sha512-WikGQpKkROJSK3D3E7odPjZ8tU7WJp5/TgGdRuZw3izsHUeH48xMv6IznafpRTmvHcjAbDQj4U3CJZNAzOK/OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/types": "3.922.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.922.0.tgz", + "integrity": "sha512-i72DgHMK7ydAEqdzU0Duqh60Q8W59EZmRJ73y0Y5oFmNOqnYsAI+UXyOoCsubp+Dkr6+yOwAn1gPt1XGE9Aowg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/types": "3.922.0", + "@smithy/fetch-http-handler": "^5.3.5", + "@smithy/node-http-handler": "^4.4.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/util-stream": "^4.5.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.925.0.tgz", + "integrity": "sha512-TOs/UkKWwXrSPolRTChpDUQjczw6KqbbanF0EzjUm3sp/AS1ThOQCKuTTdaOBZXkCIJdvRmZjF3adccE3rAoXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/credential-provider-env": "3.922.0", + "@aws-sdk/credential-provider-http": "3.922.0", + "@aws-sdk/credential-provider-process": "3.922.0", + "@aws-sdk/credential-provider-sso": "3.925.0", + "@aws-sdk/credential-provider-web-identity": "3.925.0", + "@aws-sdk/nested-clients": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@smithy/credential-provider-imds": "^4.2.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.925.0.tgz", + "integrity": "sha512-+T9mnnTY73MLkVxsk5RtzE4fv7GnMhR7iXhL/yTusf1zLfA09uxlA9VCz6tWxm5rHcO4ZN0x4hnqqDhM+DB5KQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.922.0", + "@aws-sdk/credential-provider-http": "3.922.0", + "@aws-sdk/credential-provider-ini": "3.925.0", + "@aws-sdk/credential-provider-process": "3.922.0", + "@aws-sdk/credential-provider-sso": "3.925.0", + "@aws-sdk/credential-provider-web-identity": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@smithy/credential-provider-imds": "^4.2.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.922.0.tgz", + "integrity": "sha512-1DZOYezT6okslpvMW7oA2q+y17CJd4fxjNFH0jtThfswdh9CtG62+wxenqO+NExttq0UMaKisrkZiVrYQBTShw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/types": "3.922.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.925.0.tgz", + "integrity": "sha512-aZlUC6LRsOMDvIu0ifF62mTjL3KGzclWu5XBBN8eLDAYTdhqMxv3HyrqWoiHnGZnZGaVU+II+qsVoeBnGOwHow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.925.0", + "@aws-sdk/core": "3.922.0", + "@aws-sdk/token-providers": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.925.0.tgz", + "integrity": "sha512-dR34s8Sfd1wJBzIuvRFO2FCnLmYD8iwPWrdXWI2ZypFt1EQR8jeQ20mnS+UOCoR5Z0tY6wJqEgTXKl4KuZ+DUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/nested-clients": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.922.0.tgz", + "integrity": "sha512-HPquFgBnq/KqKRVkiuCt97PmWbKtxQ5iUNLEc6FIviqOoZTmaYG3EDsIbuFBz9C4RHJU4FKLmHL2bL3FEId6AA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-logger": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.922.0.tgz", + "integrity": "sha512-AkvYO6b80FBm5/kk2E636zNNcNgjztNNUxpqVx+huyGn9ZqGTzS4kLqW2hO6CBe5APzVtPCtiQsXL24nzuOlAg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.922.0.tgz", + "integrity": "sha512-TtSCEDonV/9R0VhVlCpxZbp/9sxQvTTRKzIf8LxW3uXpby6Wl8IxEciBJlxmSkoqxh542WRcko7NYODlvL/gDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@aws/lambda-invoke-store": "^0.1.1", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.922.0.tgz", + "integrity": "sha512-N4Qx/9KP3oVQBJOrSghhz8iZFtUC2NNeSZt88hpPhbqAEAtuX8aD8OzVcpnAtrwWqy82Yd2YTxlkqMGkgqnBsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/types": "3.922.0", + "@aws-sdk/util-endpoints": "3.922.0", + "@smithy/core": "^3.17.2", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/nested-clients": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.925.0.tgz", + "integrity": "sha512-Fc8QhH+1YzGQb5aWQUX6gRnKSzUZ9p3p/muqXIgYBL8RSd5O6hSPhDTyrOWE247zFlOjVlAlEnoTMJKarH0cIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.922.0", + "@aws-sdk/middleware-host-header": "3.922.0", + "@aws-sdk/middleware-logger": "3.922.0", + "@aws-sdk/middleware-recursion-detection": "3.922.0", + "@aws-sdk/middleware-user-agent": "3.922.0", + "@aws-sdk/region-config-resolver": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@aws-sdk/util-endpoints": "3.922.0", + "@aws-sdk/util-user-agent-browser": "3.922.0", + "@aws-sdk/util-user-agent-node": "3.922.0", + "@smithy/config-resolver": "^4.4.2", + "@smithy/core": "^3.17.2", + "@smithy/fetch-http-handler": "^5.3.5", + "@smithy/hash-node": "^4.2.4", + "@smithy/invalid-dependency": "^4.2.4", + "@smithy/middleware-content-length": "^4.2.4", + "@smithy/middleware-endpoint": "^4.3.6", + "@smithy/middleware-retry": "^4.4.6", + "@smithy/middleware-serde": "^4.2.4", + "@smithy/middleware-stack": "^4.2.4", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/node-http-handler": "^4.4.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.5", + "@smithy/util-defaults-mode-node": "^4.2.8", + "@smithy/util-endpoints": "^3.2.4", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-retry": "^4.2.4", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.925.0.tgz", + "integrity": "sha512-FOthcdF9oDb1pfQBRCfWPZhJZT5wqpvdAS5aJzB1WDZ+6EuaAhLzLH/fW1slDunIqq1PSQGG3uSnVglVVOvPHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/config-resolver": "^4.4.2", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/token-providers": { + "version": "3.925.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.925.0.tgz", + "integrity": "sha512-F4Oibka1W5YYDeL+rGt/Hg3NLjOzrJdmuZOE0OFQt/U6dnJwYmYi2gFqduvZnZcD1agNm37mh7/GUq1zvKS6ig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.922.0", + "@aws-sdk/nested-clients": "3.925.0", + "@aws-sdk/types": "3.922.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/types": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.922.0.tgz", + "integrity": "sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.922.0.tgz", + "integrity": "sha512-4ZdQCSuNMY8HMlR1YN4MRDdXuKd+uQTeKIr5/pIM+g3TjInZoj8imvXudjcrFGA63UF3t92YVTkBq88mg58RXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", + "@smithy/util-endpoints": "^3.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.922.0.tgz", + "integrity": "sha512-qOJAERZ3Plj1st7M4Q5henl5FRpE30uLm6L9edZqZXGR6c7ry9jzexWamWVpQ4H4xVAVmiO9dIEBAfbq4mduOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/types": "^4.8.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.922.0.tgz", + "integrity": "sha512-NrPe/Rsr5kcGunkog0eBV+bY0inkRELsD2SacC4lQZvZiXf8VJ2Y7j+Yq1tB+h+FPLsdt3v9wItIvDf/laAm0Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.922.0", + "@aws-sdk/types": "3.922.0", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/xml-builder": { + "version": "3.921.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.921.0.tgz", + "integrity": "sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.8.1", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws/lambda-invoke-store": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", + "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/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==", + "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/client-sqs/node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/@aws-sdk/client-sso": { "version": "3.864.0", "license": "Apache-2.0", @@ -4825,6 +5353,36 @@ ], "license": "MIT" }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.922.0.tgz", + "integrity": "sha512-OJKwy247mgBVWJeSbIwz+QRVmW2L/qA5eKQivNNiSNADeETZKcJupY/3UoyrhQP08dZnFpst1Qs75E47v/tubQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.922.0", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs/node_modules/@aws-sdk/types": { + "version": "3.922.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.922.0.tgz", + "integrity": "sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/middleware-ssec": { "version": "3.893.0", "license": "Apache-2.0", @@ -9160,10 +9718,12 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.4.tgz", + "integrity": "sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9192,13 +9752,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.3.0", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.2.tgz", + "integrity": "sha512-4Jys0ni2tB2VZzgslbEgszZyMdTkPOFGA8g+So/NjR8oy6Qwaq4eSwsrRI+NMtb0Dq4kqCzGUu/nGUx7OM/xfw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/types": "^4.8.1", "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", + "@smithy/util-endpoints": "^3.2.4", + "@smithy/util-middleware": "^4.2.4", "tslib": "^2.6.2" }, "engines": { @@ -9206,16 +9769,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.15.0", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.17.2.tgz", + "integrity": "sha512-n3g4Nl1Te+qGPDbNFAYf+smkRVB+JhFsGy9uJXXZQEufoP4u0r+WLh6KvTDolCswaagysDc/afS1yvb2jnj1gQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/middleware-serde": "^4.2.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", - "@smithy/util-stream": "^4.5.0", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-stream": "^4.5.5", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" @@ -9225,13 +9790,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.4.tgz", + "integrity": "sha512-YVNMjhdz2pVto5bRdux7GMs0x1m0Afz3OcQy/4Yf9DH4fWOtroGH7uLvs7ZmDyoBJzLdegtIPpXrpJOZWvUXdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/property-provider": "^4.2.0", - "@smithy/types": "^4.6.0", - "@smithy/url-parser": "^4.2.0", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", "tslib": "^2.6.2" }, "engines": { @@ -9299,12 +9866,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.1", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.5.tgz", + "integrity": "sha512-mg83SM3FLI8Sa2ooTJbsh5MFfyMTyNRwxqpKHmE0ICRIa66Aodv80DMsTQI02xBLVJ0hckwqTRr5IGAbbWuFLQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/querystring-builder": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.4", + "@smithy/querystring-builder": "^4.2.4", + "@smithy/types": "^4.8.1", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, @@ -9326,10 +9895,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.4.tgz", + "integrity": "sha512-kKU0gVhx/ppVMntvUOZE7WRMFW86HuaxLwvqileBEjL7PoILI8/djoILw3gPQloGVE6O0oOzqafxeNi2KbnUJw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -9351,10 +9922,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.4.tgz", + "integrity": "sha512-z6aDLGiHzsMhbS2MjetlIWopWz//K+mCoPXjW6aLr0mypF+Y7qdEh5TyJ20Onf9FbWHiWl4eC+rITdizpnXqOw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9372,11 +9945,13 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.1.1", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.4.tgz", + "integrity": "sha512-h7kzNWZuMe5bPnZwKxhVbY1gan5+TZ2c9JcVTHCygB14buVGOZxLl+oGfpY2p2Xm48SFqEWdghpvbBdmaz3ncQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.5.0", - "@smithy/util-utf8": "^4.1.0", + "@smithy/types": "^4.8.1", + "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { @@ -9384,11 +9959,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.4.tgz", + "integrity": "sha512-hJRZuFS9UsElX4DJSJfoX4M1qXRH+VFiLMUnhsWvtOOUWRNvvOfDaUSdlNbjwv1IkpVjj/Rd/O59Jl3nhAcxow==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9396,16 +9973,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.1", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.15.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", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.6.tgz", + "integrity": "sha512-PXehXofGMFpDqr933rxD8RGOcZ0QBAWtuzTgYRAHAL2BnKawHDEdf/TnGpcmfPJGwonhginaaeJIKluEojiF/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.17.2", + "@smithy/middleware-serde": "^4.2.4", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", + "@smithy/url-parser": "^4.2.4", + "@smithy/util-middleware": "^4.2.4", "tslib": "^2.6.2" }, "engines": { @@ -9413,16 +9992,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.1", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/service-error-classification": "^4.2.0", - "@smithy/smithy-client": "^4.7.1", - "@smithy/types": "^4.6.0", - "@smithy/util-middleware": "^4.2.0", - "@smithy/util-retry": "^4.2.0", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.6.tgz", + "integrity": "sha512-OhLx131znrEDxZPAvH/OYufR9d1nB2CQADyYFN4C3V/NQS7Mg4V6uvxHC/Dr96ZQW8IlHJTJ+vAhKt6oxWRndA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/service-error-classification": "^4.2.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", + "@smithy/util-middleware": "^4.2.4", + "@smithy/util-retry": "^4.2.4", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, @@ -9431,11 +10012,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.4.tgz", + "integrity": "sha512-jUr3x2CDhV15TOX2/Uoz4gfgeqLrRoTQbYAuhLS7lcVKNev7FeYSJ1ebEfjk+l9kbb7k7LfzIR/irgxys5ZTOg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9443,10 +10026,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.4.tgz", + "integrity": "sha512-Gy3TKCOnm9JwpFooldwAboazw+EFYlC+Bb+1QBsSi5xI0W5lX81j/P5+CXvD/9ZjtYKRgxq+kkqd/KOHflzvgA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9454,12 +10039,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.0", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.4.tgz", + "integrity": "sha512-3X3w7qzmo4XNNdPKNS4nbJcGSwiEMsNsRSunMA92S4DJLLIrH5g1AyuOA2XKM9PAPi8mIWfqC+fnfKNsI4KvHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.0", - "@smithy/shared-ini-file-loader": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/shared-ini-file-loader": "^4.3.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9467,13 +10054,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.3.0", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.4.tgz", + "integrity": "sha512-VXHGfzCXLZeKnFp6QXjAdy+U8JF9etfpUXD1FAbzY1GzsFJiDQRQIt2CnMUvUdz3/YaHNqT3RphVWMUpXTIODA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/querystring-builder": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/abort-controller": "^4.2.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/querystring-builder": "^4.2.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9481,10 +10070,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.4.tgz", + "integrity": "sha512-g2DHo08IhxV5GdY3Cpt/jr0mkTlAD39EJKN27Jb5N8Fb5qt8KG39wVKTXiTRCmHHou7lbXR8nKVU14/aRUf86w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9492,10 +10083,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.0", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.4.tgz", + "integrity": "sha512-3sfFd2MAzVt0Q/klOmjFi3oIkxczHs0avbwrfn1aBqtc23WqQSmjvk77MBw9WkEQcwbOYIX5/2z4ULj8DuxSsw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9503,10 +10096,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.4.tgz", + "integrity": "sha512-KQ1gFXXC+WsbPFnk7pzskzOpn4s+KheWgO3dzkIEmnb6NskAIGp/dGdbKisTPJdtov28qNDohQrgDUKzXZBLig==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, @@ -9515,10 +10110,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.4.tgz", + "integrity": "sha512-aHb5cqXZocdzEkZ/CvhVjdw5l4r1aU/9iMEyoKzH4eXMowT6M0YjBpp7W/+XjkBnY8Xh0kVd55GKjnPKlCwinQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9526,20 +10123,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.4.tgz", + "integrity": "sha512-fdWuhEx4+jHLGeew9/IvqVU/fxT/ot70tpRGuOLxE3HzZOyKeTQfYeV1oaBXpzi93WOk668hjMuuagJ2/Qs7ng==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0" + "@smithy/types": "^4.8.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.3.0", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.4.tgz", + "integrity": "sha512-y5ozxeQ9omVjbnJo9dtTsdXj9BEvGx2X8xvRgKnV+/7wLBuYJQL6dOa/qMY6omyHi7yjt1OA97jZLoVRYi8lxA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9547,14 +10148,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.0", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.4.tgz", + "integrity": "sha512-ScDCpasxH7w1HXHYbtk3jcivjvdA1VICyAdgvVqKhKKwxi+MTwZEqFw0minE+oZ7F07oF25xh4FGJxgqgShz0A==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", + "@smithy/util-middleware": "^4.2.4", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -9564,15 +10167,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.7.1", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.2.tgz", + "integrity": "sha512-gZU4uAFcdrSi3io8U99Qs/FvVdRxPvIMToi+MFfsy/DN9UqtknJ1ais+2M9yR8e0ASQpNmFYEKeIKVcMjQg3rg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.15.0", - "@smithy/middleware-endpoint": "^4.3.1", - "@smithy/middleware-stack": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", - "@smithy/util-stream": "^4.5.0", + "@smithy/core": "^3.17.2", + "@smithy/middleware-endpoint": "^4.3.6", + "@smithy/middleware-stack": "^4.2.4", + "@smithy/protocol-http": "^5.3.4", + "@smithy/types": "^4.8.1", + "@smithy/util-stream": "^4.5.5", "tslib": "^2.6.2" }, "engines": { @@ -9580,7 +10185,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.6.0", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.8.1.tgz", + "integrity": "sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -9590,11 +10197,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.4.tgz", + "integrity": "sha512-w/N/Iw0/PTwJ36PDqU9PzAwVElo4qXxCC0eCTlUtIz/Z5V/2j/cViMHi0hPukSBHp4DVwvUlUhLgCzqSJ6plrg==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/querystring-parser": "^4.2.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9655,12 +10264,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.0", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.5.tgz", + "integrity": "sha512-GwaGjv/QLuL/QHQaqhf/maM7+MnRFQQs7Bsl6FlaeK6lm6U7mV5AAnVabw68cIoMl5FQFyKK62u7RWRzWL25OQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.0", - "@smithy/smithy-client": "^4.7.1", - "@smithy/types": "^4.6.0", + "@smithy/property-provider": "^4.2.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9668,15 +10279,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.1", + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.8.tgz", + "integrity": "sha512-gIoTf9V/nFSIZ0TtgDNLd+Ws59AJvijmMDYrOozoMHPJaG9cMRdqNO50jZTlbM6ydzQYY8L/mQ4tKSw/TB+s6g==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.3.0", - "@smithy/credential-provider-imds": "^4.2.0", - "@smithy/node-config-provider": "^4.3.0", - "@smithy/property-provider": "^4.2.0", - "@smithy/smithy-client": "^4.7.1", - "@smithy/types": "^4.6.0", + "@smithy/config-resolver": "^4.4.2", + "@smithy/credential-provider-imds": "^4.2.4", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/property-provider": "^4.2.4", + "@smithy/smithy-client": "^4.9.2", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9684,11 +10297,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.0", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.4.tgz", + "integrity": "sha512-f+nBDhgYRCmUEDKEQb6q0aCcOTXRDqH5wWaFHJxt4anB4pKHlgGoYP3xtioKXH64e37ANUkzWf6p4Mnv1M5/Vg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/node-config-provider": "^4.3.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9706,10 +10321,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.4.tgz", + "integrity": "sha512-fKGQAPAn8sgV0plRikRVo6g6aR0KyKvgzNrPuM74RZKy/wWVzx3BMk+ZWEueyN3L5v5EDg+P582mKU+sH5OAsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9717,11 +10334,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.4.tgz", + "integrity": "sha512-yQncJmj4dtv/isTXxRb4AamZHy4QFr4ew8GxS6XLWt7sCIxkPxPzINWd7WLISEFPsIan14zrKgvyAF+/yzfwoA==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/service-error-classification": "^4.2.4", + "@smithy/types": "^4.8.1", "tslib": "^2.6.2" }, "engines": { @@ -9729,12 +10348,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.0", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.5.tgz", + "integrity": "sha512-7M5aVFjT+HPilPOKbOmQfCIPchZe4DSBc1wf1+NvHvSoFTiFtauZzT+onZvCj70xhXd0AEmYnZYmdJIuwxOo4w==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.1", - "@smithy/node-http-handler": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/fetch-http-handler": "^5.3.5", + "@smithy/node-http-handler": "^4.4.4", + "@smithy/types": "^4.8.1", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml index ec700064f..0f80ee80b 100644 --- a/sandbox/api/openapi.yaml +++ b/sandbox/api/openapi.yaml @@ -459,6 +459,8 @@ paths: It is not possible to update a letter to status of `PENDING`. + The request should not contain multiple letter objects with the same ID. + Optionally a `reasonCode` and `reasonText` explaining the status (for example, validation failures) can be included in the request body for each update. operationId: postLetters parameters: @@ -561,23 +563,23 @@ paths: example: 11C46F5F-CDEF-4865-94B2-0EE0EDCC26DA type: string style: simple - "404": + "400": content: application/vnd.api+json: examples: - error-not-found: + error-bad-request-invalid-request-body: value: errors: - - code: NOTIFY_RESOURCE_NOT_FOUND - detail: No resource found with that ID + - code: NOTIFY_INVALID_REQUEST + detail: The request body is invalid id: rrt-1931948104716186917-c-geu2-10664-3111479-3.0 links: about: https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier - status: "404" - title: Resource not found + status: "400" + title: Invalid request schema: $ref: "#/components/schemas/listLetters_400_response" - description: Resource not found + description: "Bad request, invalid input data" "429": content: application/vnd.api+json: diff --git a/sandbox/services/LetterService.js b/sandbox/services/LetterService.js index 609c5a2f9..a603d5fa6 100644 --- a/sandbox/services/LetterService.js +++ b/sandbox/services/LetterService.js @@ -111,7 +111,7 @@ const patchLetter = ({ xRequestId, id, body, xCorrelationId }) => new Promise( * xRequestId String Unique request identifier, in the format of a GUID * postLettersRequest PostLettersRequest * xCorrelationId String An optional ID which you can use to track transactions across multiple systems. It can take any value, but we recommend avoiding `.` characters. If not provided in the request, NHS Notify will default to a system generated ID in its place. The ID will be returned in a response header. (optional) -* returns listLetters_200_response +* returns 202 * */ const postLetters = ({ xRequestId, body, xCorrelationId }) => new Promise( async (resolve, reject) => { diff --git a/sandbox/utils/ResponseProvider.js b/sandbox/utils/ResponseProvider.js index 1e091637b..44dfff0dc 100644 --- a/sandbox/utils/ResponseProvider.js +++ b/sandbox/utils/ResponseProvider.js @@ -93,10 +93,10 @@ async function patchLetterResponse(request) { } async function postLettersResponse(request) { - const patchLetterFileMap = { + const postLettersFileMap = { 'data/examples/postLetter/requests/postLetters.json': {responsePath: 'data/examples/postLetter/responses/postLetters.json', responseCode: 202}, }; - return await mapExampleResponse(request, patchLetterFileMap); + return await mapExampleResponse(request, postLettersFileMap); } async function postMIResponse(request) { diff --git a/specification/api/components/documentation/postLetters.md b/specification/api/components/documentation/postLetters.md index b067b6881..e1b2b1273 100644 --- a/specification/api/components/documentation/postLetters.md +++ b/specification/api/components/documentation/postLetters.md @@ -25,6 +25,8 @@ Allowed `status` values that can be used to are: It is not possible to update a letter to status of `PENDING`. +The request should not contain multiple letter objects with the same ID. + Optionally a `reasonCode` and `reasonText` explaining the status (for example, validation failures) can be included in the request body for each update. ### Example Error Codes