diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index c1a14fe9b..fd16d4e6a 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,7 @@ No requirements. | [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a | | [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | -| [patch\_letters](#module\_patch\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | +| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a | | [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a | | [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-ssl.zip | n/a | ## Outputs diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf index d73e9201b..4ceca3cd4 100644 --- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf @@ -50,7 +50,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { resources = [ module.authorizer_lambda.function_arn, module.get_letters.function_arn, - module.patch_letters.function_arn + module.patch_letter.function_arn ] } } diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 1ee9232f0..982bc1104 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -9,15 +9,15 @@ locals { AWS_REGION = var.region AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn - PATCH_LETTERS_LAMBDA_ARN = module.patch_letters.function_arn + PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn }) destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" - common_db_access_lambda_env_vars = { + common_lambda_env_vars = { LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, LETTER_TTL_HOURS = 24, SUPPLIER_ID_HEADER = "nhsd-supplier-id" - SUPPLIER_ID_HEADER = "nhsd-correlation-id" + APIM_CORRELATION_HEADER = "nhsd-correlation-id" } } diff --git a/infrastructure/terraform/components/api/module_lambda_get_letters.tf b/infrastructure/terraform/components/api/module_lambda_get_letters.tf index d654fa174..2695a8f86 100644 --- a/infrastructure/terraform/components/api/module_lambda_get_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_get_letters.tf @@ -35,7 +35,7 @@ module "get_letters" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_db_access_lambda_env_vars, { + lambda_env_vars = merge(local.common_lambda_env_vars, { MAX_LIMIT = var.max_get_limit }) } diff --git a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf similarity index 85% rename from infrastructure/terraform/components/api/module_lambda_patch_letters.tf rename to infrastructure/terraform/components/api/module_lambda_patch_letter.tf index c99388572..568eff568 100644 --- a/infrastructure/terraform/components/api/module_lambda_patch_letters.tf +++ b/infrastructure/terraform/components/api/module_lambda_patch_letter.tf @@ -1,7 +1,7 @@ -module "patch_letters" { +module "patch_letter" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip" - function_name = "patch_letters" + function_name = "patch_letter" description = "Update the status of a letter" aws_account_id = var.aws_account_id @@ -15,14 +15,14 @@ module "patch_letters" { kms_key_arn = module.kms.key_arn iam_policy_document = { - body = data.aws_iam_policy_document.patch_letters_lambda.json + body = data.aws_iam_policy_document.patch_letter_lambda.json } function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] function_code_base_path = local.aws_lambda_functions_dir_path function_code_dir = "api-handler/dist" function_include_common = true - handler_function_name = "patchLetters" + handler_function_name = "patchLetter" runtime = "nodejs22.x" memory = 128 timeout = 5 @@ -35,10 +35,10 @@ module "patch_letters" { log_destination_arn = local.destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - lambda_env_vars = merge(local.common_db_access_lambda_env_vars, {}) + lambda_env_vars = merge(local.common_lambda_env_vars, {}) } -data "aws_iam_policy_document" "patch_letters_lambda" { +data "aws_iam_policy_document" "patch_letter_lambda" { statement { sid = "KMSPermissions" effect = "Allow" diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json index 193d08d18..ec58b67e4 100644 --- a/infrastructure/terraform/components/api/resources/spec.tmpl.json +++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json @@ -67,7 +67,7 @@ ], "patch": { "description": "Update the status of a letter by providing the new status in the request body.", - "operationId": "patchLetters", + "operationId": "patchLetter", "requestBody": { "required": true }, @@ -102,7 +102,7 @@ }, "timeoutInMillis": 29000, "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTERS_LAMBDA_ARN}/invocations" + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations" } } } diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index 190084474..cd7da96fb 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -4,6 +4,7 @@ import { Letter } from '../types'; import { Logger } from 'pino'; import { createTestLogger, LogStream } from './logs'; import { PutCommand } from '@aws-sdk/lib-dynamodb'; +import { LetterDto } from '../../../../lambdas/api-handler/src/contracts/letters'; function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit { return { @@ -106,7 +107,14 @@ describe('LetterRepository', () => { await letterRepository.putLetter(letter); await checkLetterStatus('supplier1', 'letter1', 'PENDING'); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'REJECTED', 1, "Reason text"); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'REJECTED', + reasonCode: 1, + reasonText: 'Reason text' + }; + await letterRepository.updateLetterStatus(letterDto); const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); expect(updatedLetter.status).toBe('REJECTED'); @@ -124,13 +132,25 @@ describe('LetterRepository', () => { // Month is zero-indexed in JavaScript Date // Day is one-indexed jest.setSystemTime(new Date(2020, 1, 2)); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + + await letterRepository.updateLetterStatus(letterDto); const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); + expect(updatedLetter.updatedAt).toBe('2020-02-02T00:00:00.000Z'); }); test('can\'t update a letter that does not exist', async () => { - await expect(letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined)) + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await expect(letterRepository.updateLetterStatus(letterDto)) .rejects.toThrow('Letter with id letter1 not found for supplier supplier1'); }); @@ -139,7 +159,13 @@ describe('LetterRepository', () => { ...db.config, lettersTableName: 'nonexistent-table' }); - await expect(misconfiguredRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined)) + + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await expect(misconfiguredRepository.updateLetterStatus(letterDto)) .rejects.toThrow('Cannot do operations on a non-existent table'); }); @@ -164,7 +190,12 @@ describe('LetterRepository', () => { const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); expect(pendingLetters.letters).toHaveLength(2); - await letterRepository.updateLetterStatus('supplier1', 'letter1', 'DELIVERED', undefined, undefined); + const letterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'DELIVERED' + }; + await letterRepository.updateLetterStatus(letterDto); const remainingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); expect(remainingLetters.letters).toHaveLength(1); expect(remainingLetters.letters[0].id).toBe('letter2'); diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index aa379aed5..2afe0792a 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -10,6 +10,7 @@ import { import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from './types'; import { Logger } from 'pino'; import { z } from 'zod'; +import { LetterDto } from '../../../lambdas/api-handler/src/contracts/letters'; export type PagingOptions = Partial<{ exclusiveStartKey: Record, @@ -133,37 +134,35 @@ export class LetterRepository { } } - async updateLetterStatus(supplierId: string, letterId: string, status: Letter['status'], reasonCode: number | undefined, reasonText: string | undefined): Promise { - this.log.debug(`Updating letter ${letterId} to status ${status}`); + async updateLetterStatus(letterToUpdate: LetterDto): Promise { + this.log.debug(`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); let result: UpdateCommandOutput; try { let updateExpression = 'set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl'; - let expressionAttributeValues = { - ':status': status, - ':updatedAt': new Date().toISOString(), - ':supplierStatus': `${supplierId}#${status}`, - ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours), - ...(!reasonCode && {':reasonCode': reasonCode}), - ...(!reasonText && {':reasonText': reasonText}) - }; - - if (reasonCode) + let expressionAttributeValues : Record = { + ':status': letterToUpdate.status, + ':updatedAt': new Date().toISOString(), + ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, + ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours) + }; + + if (letterToUpdate.reasonCode) { updateExpression += ', reasonCode = :reasonCode'; - expressionAttributeValues[':reasonCode'] = reasonCode; + expressionAttributeValues[':reasonCode'] = letterToUpdate.reasonCode; } - if (reasonText) + if (letterToUpdate.reasonText) { updateExpression += ', reasonText = :reasonText'; - expressionAttributeValues[':reasonText'] = reasonText; + expressionAttributeValues[':reasonText'] = letterToUpdate.reasonText; } result = await this.ddbClient.send(new UpdateCommand({ TableName: this.config.lettersTableName, Key: { - supplierId: supplierId, - id: letterId + supplierId: letterToUpdate.supplierId, + id: letterToUpdate.id }, UpdateExpression: updateExpression, ConditionExpression: 'attribute_exists(id)', // Ensure letter exists @@ -176,12 +175,12 @@ export class LetterRepository { })); } catch (error) { if (error instanceof Error && error.name === 'ConditionalCheckFailedException') { - throw new Error(`Letter with id ${letterId} not found for supplier ${supplierId}`); + throw new Error(`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`); } throw error; } - this.log.debug(`Updated letter ${letterId} to status ${status}`); + this.log.debug(`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); return LetterSchema.parse(result.Attributes); } diff --git a/lambdas/api-handler/src/contracts/json-api.ts b/lambdas/api-handler/src/contracts/json-api.ts new file mode 100644 index 000000000..46607d293 --- /dev/null +++ b/lambdas/api-handler/src/contracts/json-api.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// Single document wrapper +export const makeDocumentSchema = (resourceSchema: T) => + z.object({ data: resourceSchema }).strict(); + +// Collection document wrapper +export const makeCollectionSchema = (resourceSchema: T) => + z.object({ data: z.array(resourceSchema) }).strict(); diff --git a/lambdas/api-handler/src/contracts/letter-api.ts b/lambdas/api-handler/src/contracts/letter-api.ts deleted file mode 100644 index ec3c8647f..000000000 --- a/lambdas/api-handler/src/contracts/letter-api.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; - -export const LetterApiStatusSchema = z.enum([ - "PENDING", - "ACCEPTED", - "REJECTED", - "PRINTED", - "ENCLOSED", - "CANCELLED", - "DISPATCHED", - "FAILED", - "RETURNED", - "DESTROYED", - "FORWARDED", - "DELIVERED", -]); - -export type LetterApiStatus = z.infer; - -export const LetterApiAttributesSchema = z.object({ - status: LetterApiStatusSchema, - specificationId: z.string(), - groupId: z.string().optional(), - reasonCode: z.number().optional(), - reasonText: z.string().optional(), -}); - -export type LetterApiAttributes = z.infer; - -export const LetterApiResourceSchema = z.object({ - id: z.string(), - type: z.literal("Letter"), - attributes: LetterApiAttributesSchema -}); - -export type LetterApiResource = z.infer; - -export const LetterApiDocumentSchema = z.object({ - data: LetterApiResourceSchema -}); - -export const LettersApiDocumentSchema = z.object({ - data: z.array(LetterApiResourceSchema) -}); - -export type LetterApiDocument = z.infer; - -export type LettersApiDocument = z.infer; diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts new file mode 100644 index 000000000..26672c14a --- /dev/null +++ b/lambdas/api-handler/src/contracts/letters.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { makeCollectionSchema, makeDocumentSchema } from './json-api'; + +export type LetterDto = { + id: string, + status: LetterStatus, + supplierId: string, + specificationId?: string, + groupId?: string, + reasonCode?: number, + reasonText?: string +}; + +export const LetterStatusSchema = z.enum([ + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'PRINTED', + 'ENCLOSED', + 'CANCELLED', + 'DISPATCHED', + 'FAILED', + 'RETURNED', + 'DESTROYED', + 'FORWARDED', + 'DELIVERED' +]); + +export const PatchLetterRequestResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + reasonCode: z.number().optional(), + reasonText: z.string().optional(), + }).strict() +}).strict(); + +export const PatchLetterResponseResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + specificationId: z.string(), + groupId: z.string().optional(), + reasonCode: z.number().optional(), + reasonText: z.string().optional(), + }).strict() +}).strict(); + +export const GetLettersResponseResourceSchema = z.object({ + id: z.string(), + type: z.literal('Letter'), + attributes: z.object({ + status: LetterStatusSchema, + specificationId: z.string(), + groupId: z.string().optional(), + }).strict() +}).strict(); + +export type LetterStatus = z.infer; + +export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema); +export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema); +export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema); + +export type PatchLetterRequest = z.infer; +export type PatchLetterResponse = z.infer; +export type GetLettersResponse = z.infer; diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts index 7a0d1baa5..a1ef63a1e 100644 --- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts @@ -65,8 +65,8 @@ describe('API Lambda handler', () => { specificationId: "s1", groupId: 'g1', status: "PENDING", - reasonCode: 123, - reasonText: "Reason text" + reasonCode: 123, // shouldn't be returned if present + reasonText: "Reason text" // shouldn't be returned if present }, ]); diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts similarity index 73% rename from lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts rename to lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts index c649d6faf..83f122ecc 100644 --- a/lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts +++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts @@ -1,9 +1,9 @@ -import { patchLetters } from '../../index'; +import { patchLetter } from '../../index'; import { APIGatewayProxyResult, Context } from 'aws-lambda'; import { mockDeep } from 'jest-mock-extended'; import { makeApiGwEvent } from './utils/test-utils'; import * as letterService from '../../services/letter-operations'; -import { LetterApiDocument, LetterApiStatus } from '../../contracts/letter-api'; +import { PatchLetterRequest, PatchLetterResponse } from '../../contracts/letters'; import { mapErrorToResponse } from '../../mappers/error-mapper'; import { ValidationError } from '../../errors'; import * as errors from '../../contracts/errors'; @@ -11,9 +11,9 @@ import * as errors from '../../contracts/errors'; jest.mock('../../services/letter-operations'); jest.mock('../../mappers/error-mapper'); -jest.mock("../../config/lambda-config", () => ({ +jest.mock('../../config/lambda-config', () => ({ lambdaConfig: { - SUPPLIER_ID_HEADER: "nhsd-supplier-id", + SUPPLIER_ID_HEADER: 'nhsd-supplier-id', APIM_CORRELATION_HEADER: 'nhsd-correlation-id' } })); @@ -21,66 +21,75 @@ jest.mock("../../config/lambda-config", () => ({ const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse); const expectedErrorResponse: APIGatewayProxyResult = { statusCode: 400, - body: "Error" + body: 'Error' }; mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse); const mockedPatchLetterStatus = jest.mocked(letterService.patchLetterStatus); -const letterApiDocument = makeLetterApiDocument("id1", "REJECTED"); -const requestBody = JSON.stringify(letterApiDocument, null, 2); - -function makeLetterApiDocument(id: string, status: LetterApiStatus) : LetterApiDocument { - return { +const updateLetterStatusRequest : PatchLetterRequest = { data: { + id: 'id1', + type: 'Letter', attributes: { + status: 'REJECTED', reasonCode: 123, - reasonText: "Reason text", - specificationId: "spec1", - status - }, - id, - type: "Letter" + reasonText: 'Reason text', + } } - }; -} +}; + +const requestBody = JSON.stringify(updateLetterStatusRequest, null, 2); beforeEach(() => { jest.clearAllMocks(); }); -describe('patchLetters API Handler', () => { +describe('patchLetter API Handler', () => { it('returns 200 OK with updated resource', async () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - mockedPatchLetterStatus.mockResolvedValue(letterApiDocument); - - const result = await patchLetters(event, context, callback); + const updateLetterServiceResponse : PatchLetterResponse = { + data: { + id: 'id1', + type: 'Letter', + attributes: { + status: 'REJECTED', + specificationId: 'spec1', + groupId: 'group1', + reasonCode: 123, + reasonText: 'Reason text', + } + } + }; + mockedPatchLetterStatus.mockResolvedValue(updateLetterServiceResponse); + + const result = await patchLetter(event, context, callback); expect(result).toEqual({ statusCode: 200, - body: requestBody, + body: JSON.stringify(updateLetterServiceResponse, null, 2) }); }); it('returns error response when there is no body', async () => { const event = makeApiGwEvent({ path: '/letters/id1', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -94,7 +103,7 @@ describe('patchLetters API Handler', () => { }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -107,13 +116,13 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -123,13 +132,13 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -138,14 +147,14 @@ describe('patchLetters API Handler', () => { it('returns error when request body does not have correct shape', async () => { const event = makeApiGwEvent({ path: '/letters/id1', - body: '{test: "test"}', - pathParameters: {id: "id1"}, + body: "{test: 'test'}", + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -155,13 +164,13 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: '{#invalidJSON', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestBody), 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -171,18 +180,18 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: 'somebody', - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'} }); const context = mockDeep(); const callback = jest.fn(); - const error = "Unexpected error"; - const spy = jest.spyOn(JSON, "parse").mockImplementation(() => { + const error = 'Unexpected error'; + const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => { throw error; }); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(error, 'correlationId'); expect(result).toEqual(expectedErrorResponse); @@ -190,17 +199,17 @@ describe('patchLetters API Handler', () => { spy.mockRestore(); }); - it("returns error if correlation id not provided in request", async () => { + it('returns error if correlation id not provided in request', async () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {'nhsd-supplier-id': 'supplier1'} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined); expect(result).toEqual(expectedErrorResponse); @@ -210,13 +219,13 @@ describe('patchLetters API Handler', () => { const event = makeApiGwEvent({ path: '/letters/id1', body: requestBody, - pathParameters: {id: "id1"}, + pathParameters: {id: 'id1'}, headers: {} }); const context = mockDeep(); const callback = jest.fn(); - const result = await patchLetters(event, context, callback); + const result = await patchLetter(event, context, callback); expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined); expect(result).toEqual(expectedErrorResponse); diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 0c24ca4b1..1f6f7df91 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -1,14 +1,13 @@ import { APIGatewayProxyEventQueryStringParameters, APIGatewayProxyHandler } from "aws-lambda"; import { getLettersForSupplier } from "../services/letter-operations"; import { createLetterRepository } from "../infrastructure/letter-repo-factory"; -import { LetterBase } from "../../../../internal/datastore/src"; -import { assertNotEmpty } from "../utils/validation"; +import { assertNotEmpty, lowerCaseKeys } from "../utils/validation"; import { ApiErrorDetail } from '../contracts/errors'; import { lambdaConfig } from "../config/lambda-config"; import pino from 'pino'; import { mapErrorToResponse } from "../mappers/error-mapper"; import { ValidationError } from "../errors"; -import { mapLetterBaseToApiResource } from "../mappers/letter-mapper"; +import { mapToGetLettersResponse } from "../mappers/letter-mapper"; const letterRepo = createLetterRepository(); @@ -28,8 +27,9 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { try { assertNotEmpty(event.headers, new Error("The request headers are empty")); - correlationId = assertNotEmpty(event.headers[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(event.headers[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit); const letters = await getLettersForSupplier( @@ -39,9 +39,7 @@ export const getLetters: APIGatewayProxyHandler = async (event) => { letterRepo, ); - const response = { - data: letters.map((letter: LetterBase) => (mapLetterBaseToApiResource(letter, { excludeOptional: true }))) - }; + const response = mapToGetLettersResponse(letters); log.info({ description: 'Pending letters successfully fetched', diff --git a/lambdas/api-handler/src/handlers/patch-letters.ts b/lambdas/api-handler/src/handlers/patch-letter.ts similarity index 56% rename from lambdas/api-handler/src/handlers/patch-letters.ts rename to lambdas/api-handler/src/handlers/patch-letter.ts index 558ac7ec4..c381d5bf3 100644 --- a/lambdas/api-handler/src/handlers/patch-letters.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,29 +1,31 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; import { createLetterRepository } from '../infrastructure/letter-repo-factory'; import { patchLetterStatus } from '../services/letter-operations'; -import { LetterApiDocument, LetterApiDocumentSchema } from '../contracts/letter-api'; +import { PatchLetterRequest, PatchLetterRequestSchema } from '../contracts/letters'; import { ApiErrorDetail } from '../contracts/errors'; import { ValidationError } from '../errors'; import { mapErrorToResponse } from '../mappers/error-mapper'; import { lambdaConfig } from "../config/lambda-config"; -import { assertNotEmpty } from '../utils/validation'; +import { assertNotEmpty, lowerCaseKeys } from '../utils/validation'; +import { mapToLetterDto } from '../mappers/letter-mapper'; const letterRepo = createLetterRepository(); -export const patchLetters: APIGatewayProxyHandler = async (event) => { +export const patchLetter: APIGatewayProxyHandler = async (event) => { let correlationId; try { assertNotEmpty(event.headers, new Error('The request headers are empty')); - correlationId = assertNotEmpty(event.headers[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); - const supplierId = assertNotEmpty(event.headers[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); + const lowerCasedHeaders = lowerCaseKeys(event.headers); + correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER], new Error("The request headers don't contain the APIM correlation id")); + const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER], new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId)); const letterId = assertNotEmpty( event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter)); const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody)); - let patchLetterRequest: LetterApiDocument; + let patchLetterRequest: PatchLetterRequest; try { - patchLetterRequest = LetterApiDocumentSchema.parse(JSON.parse(body)); + patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); } catch (error) { if (error instanceof Error) { throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error}); @@ -31,7 +33,7 @@ export const patchLetters: APIGatewayProxyHandler = async (event) => { else throw error; } - const result = await patchLetterStatus(patchLetterRequest.data, letterId!, supplierId!, letterRepo); + const result = await patchLetterStatus(mapToLetterDto(patchLetterRequest, supplierId), letterId, letterRepo); return { statusCode: 200, diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts index 5b6d4c6f1..203102f73 100644 --- a/lambdas/api-handler/src/index.ts +++ b/lambdas/api-handler/src/index.ts @@ -1,3 +1,3 @@ // Export all handlers for ease of access export { getLetters } from './handlers/get-letters'; -export { patchLetters } from './handlers/patch-letters'; +export { patchLetter } from './handlers/patch-letter'; diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index 82adabd28..96548cda8 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -1,9 +1,9 @@ -import { mapLetterBaseToApiDocument, mapLetterBaseToApiResource } from '../letter-mapper'; +import { mapToGetLettersResponse, mapToPatchLetterResponse } from '../letter-mapper'; import { Letter } from '../../../../../internal/datastore'; -import { LetterApiDocument, LetterApiResource } from '../../contracts/letter-api'; +import { GetLettersResponse, PatchLetterResponse } from '../../contracts/letters'; describe('letter-mapper', () => { - it('maps a Letter to LetterApiDocument', () => { + it('maps an internal Letter to a PatchLetterResponse', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -18,7 +18,7 @@ describe('letter-mapper', () => { ttl: 123 }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter); + const result: PatchLetterResponse = mapToPatchLetterResponse(letter); expect(result).toEqual({ data: { @@ -33,7 +33,7 @@ describe('letter-mapper', () => { }); }); - it('maps a Letter to LetterApiDocument with reasonCode and reasonText when present', () => { + it('maps an internal Letter to a PatchLetterResponse with reasonCode and reasonText when present', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -50,7 +50,7 @@ describe('letter-mapper', () => { reasonText: 'Reason text' }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter, {excludeOptional:false}); + const result: PatchLetterResponse = mapToPatchLetterResponse(letter); expect(result).toEqual({ data: { @@ -67,7 +67,7 @@ describe('letter-mapper', () => { }); }); - it('maps a Letter to LetterApiDocument without reasonCode and reasonText when present', () => { + it('maps an internal Letter collection to a GetLettersResponse', () => { const letter: Letter = { id: 'abc123', status: 'PENDING', @@ -84,51 +84,29 @@ describe('letter-mapper', () => { reasonText: 'Reason text' }; - const result: LetterApiDocument = mapLetterBaseToApiDocument(letter, {excludeOptional: true}); + const result: GetLettersResponse = mapToGetLettersResponse([letter, letter]); expect(result).toEqual({ - data: { - id: 'abc123', - type: 'Letter', - attributes: { - specificationId: 'spec123', - status: 'PENDING', - groupId: 'group123' + data: [ + { + id: 'abc123', + type: 'Letter', + attributes: { + specificationId: 'spec123', + status: 'PENDING', + groupId: 'group123' + } + }, + { + id: 'abc123', + type: 'Letter', + attributes: { + specificationId: 'spec123', + status: 'PENDING', + groupId: 'group123' + } } - } - }); - }); - - - it('maps a Letter to LetterApiResource with reasonCode and reasonText when present', () => { - const letter: Letter = { - id: 'abc123', - status: 'PENDING', - supplierId: 'supplier1', - specificationId: 'spec123', - groupId: 'group123', - url: 'https://example.com/letter/abc123', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - supplierStatus: 'supplier1#PENDING', - supplierStatusSk: Date.now().toString(), - ttl: 123, - reasonCode: 123, - reasonText: 'Reason text' - }; - - const result: LetterApiResource = mapLetterBaseToApiResource(letter); - - expect(result).toEqual({ - id: 'abc123', - type: 'Letter', - attributes: { - specificationId: 'spec123', - status: 'PENDING', - groupId: 'group123', - reasonCode: 123, - reasonText: 'Reason text' - } + ] }); }); }); diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index c9c55ab21..e35bcb00c 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -1,22 +1,46 @@ -import { LetterBase } from "../../../../internal/datastore"; -import { LetterApiDocument, LetterApiDocumentSchema, LetterApiResource, LetterApiResourceSchema } from '../contracts/letter-api'; +import { LetterBase, LetterStatus } from "../../../../internal/datastore"; +import { GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters'; -export function mapLetterBaseToApiDocument(letterBase: LetterBase, opts: { excludeOptional: boolean } = { excludeOptional: false }): LetterApiDocument { - return LetterApiDocumentSchema.parse({ - data: mapLetterBaseToApiResource(letterBase, opts) +export function mapToLetterDto(request: PatchLetterRequest, supplierId: string) : LetterDto { + return { + id: request.data.id, + supplierId, + status: LetterStatus.parse(request.data.attributes.status), + reasonCode: request.data.attributes.reasonCode, + reasonText: request.data.attributes.reasonText, + }; +} + +export function mapToPatchLetterResponse(letter: LetterBase): PatchLetterResponse { + return PatchLetterResponseSchema.parse({ + data: { + id: letter.id, + type: 'Letter', + attributes: { + status: letter.status, + specificationId: letter.specificationId, + groupId: letter.groupId, + ...(letter.reasonCode != null && { reasonCode: letter.reasonCode }), + ...(letter.reasonText != null && { reasonText: letter.reasonText }) + } + } }); } -export function mapLetterBaseToApiResource(letterBase: LetterBase, opts: { excludeOptional: boolean } = { excludeOptional: false }): LetterApiResource { - return LetterApiResourceSchema.parse({ - id: letterBase.id, +export function mapToGetLettersResponse(letters: LetterBase[]): GetLettersResponse { + return GetLettersResponseSchema.parse({ + data: letters.map(letterToResourceResponse) + }); +} + +function letterToResourceResponse(letter: LetterBase) { + return { + id: letter.id, type: 'Letter', attributes: { - status: letterBase.status, - specificationId: letterBase.specificationId, - groupId: letterBase.groupId, - ...(letterBase.reasonCode && !opts.excludeOptional && { reasonCode: letterBase.reasonCode }), - ...(letterBase.reasonText && !opts.excludeOptional && { reasonText: letterBase.reasonText }) + status: letter.status, + specificationId: letter.specificationId, + groupId: letter.groupId } - }); + }; } diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts index 1a6f82f45..7420c3110 100644 --- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts +++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts @@ -1,20 +1,7 @@ import { Letter } from '../../../../../internal/datastore/src'; -import { LetterApiResource, LetterApiStatus } from '../../contracts/letter-api'; +import { LetterDto, LetterStatus } from '../../contracts/letters'; import { getLettersForSupplier, patchLetterStatus } from '../letter-operations'; -function makeLetterApiResource(id: string, status: LetterApiStatus) : LetterApiResource { - return { - attributes: { - specificationId: "spec123", - status, - groupId: 'group123', - reasonCode: 123, - reasonText: "Reason text" - }, - id, - type: "Letter" - }; -} function makeLetter(id: string, status: Letter['status']) : Letter { return { @@ -64,7 +51,13 @@ describe("getLetterIdsForSupplier", () => { describe('patchLetterStatus function', () => { - const letterResource = makeLetterApiResource("letter1", "REJECTED"); + const updatedLetterDto: LetterDto = { + id: 'letter1', + supplierId: 'supplier1', + status: 'REJECTED', + reasonCode: 123, + reasonText: 'Reason text' + }; const updatedLetter = makeLetter("letter1", "REJECTED"); @@ -73,13 +66,25 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockResolvedValue(updatedLetter) }; - const result = await patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any); - - expect(result).toEqual({ data: letterResource}); + const result = await patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any); + + expect(result).toEqual({ data: + { + id: 'letter1', + type: 'Letter', + attributes: { + status: 'REJECTED', + reasonCode: updatedLetter.reasonCode, + reasonText: updatedLetter.reasonText, + specificationId: updatedLetter.specificationId, + groupId: updatedLetter.groupId + }, + } + }); }); it('should throw validationError when letterIds differ', async () => { - await expect(patchLetterStatus(letterResource, 'letter2', "supplier1", {} as any)).rejects.toThrow("The letter ID in the request body does not match the letter ID path parameter"); + await expect(patchLetterStatus(updatedLetterDto, 'letter2', {} as any)).rejects.toThrow("The letter ID in the request body does not match the letter ID path parameter"); }); it('should throw notFoundError when letter does not exist', async () => { @@ -87,7 +92,7 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1')) }; - await expect(patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any)).rejects.toThrow("No resource found with that ID"); + await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("No resource found with that ID"); }); it('should throw unexpected error', async () => { @@ -96,6 +101,6 @@ describe('patchLetterStatus function', () => { updateLetterStatus: jest.fn().mockRejectedValue(new Error('unexpected error')) }; - await expect(patchLetterStatus(letterResource, 'letter1', 'supplier1', mockRepo as any)).rejects.toThrow("unexpected error"); + await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("unexpected error"); }); }); diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts index c0104b946..4344ef95e 100644 --- a/lambdas/api-handler/src/services/letter-operations.ts +++ b/lambdas/api-handler/src/services/letter-operations.ts @@ -1,7 +1,7 @@ import { LetterBase, LetterRepository } from '../../../../internal/datastore/src' import { NotFoundError, ValidationError } from '../errors'; -import { LetterApiResource, LetterApiDocument } from '../contracts/letter-api'; -import { mapLetterBaseToApiDocument } from '../mappers/letter-mapper'; +import { LetterDto, PatchLetterResponse } from '../contracts/letters'; +import { mapToPatchLetterResponse } from '../mappers/letter-mapper'; import { ApiErrorDetail } from '../contracts/errors'; @@ -10,7 +10,7 @@ export const getLettersForSupplier = async (supplierId: string, status: string, return await letterRepo.getLettersBySupplier(supplierId, status, limit); } -export const patchLetterStatus = async (letterToUpdate: LetterApiResource, letterId: string, supplierId: string, letterRepo: LetterRepository): Promise => { +export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: string, letterRepo: LetterRepository): Promise => { if (letterToUpdate.id !== letterId) { throw new ValidationError(ApiErrorDetail.InvalidRequestLetterIdsMismatch); @@ -19,8 +19,7 @@ export const patchLetterStatus = async (letterToUpdate: LetterApiResource, lette let updatedLetter; try { - updatedLetter = await letterRepo.updateLetterStatus(supplierId, letterId, letterToUpdate.attributes.status, - letterToUpdate.attributes.reasonCode, letterToUpdate.attributes.reasonText); + updatedLetter = await letterRepo.updateLetterStatus(letterToUpdate); } catch (error) { if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) { throw new NotFoundError(ApiErrorDetail.NotFoundLetterId); @@ -28,5 +27,5 @@ export const patchLetterStatus = async (letterToUpdate: LetterApiResource, lette throw error; } - return mapLetterBaseToApiDocument(updatedLetter); + return mapToPatchLetterResponse(updatedLetter); } diff --git a/lambdas/api-handler/src/utils/__tests__/validation.test.ts b/lambdas/api-handler/src/utils/__tests__/validation.test.ts index c35bc332f..8725cabb5 100644 --- a/lambdas/api-handler/src/utils/__tests__/validation.test.ts +++ b/lambdas/api-handler/src/utils/__tests__/validation.test.ts @@ -1,4 +1,4 @@ -import { assertNotEmpty } from "../validation"; +import { assertNotEmpty, lowerCaseKeys } from "../validation"; describe("assertNotEmpty", () => { const error = new Error(); @@ -52,3 +52,16 @@ describe("assertNotEmpty", () => { expect(result).toBe(arr); }); }); + +describe("lowerCaseKeys", () => { + it("lowers case on header keys", () => { + const headers: Record = {'Aa_Bb-Cc':1, 'b':2}; + const result = lowerCaseKeys(headers); + expect(result).toEqual({'aa_bb-cc':1, 'b':2}); + }); + + it("handles empty input", () => { + const result = lowerCaseKeys({}); + expect(result).toEqual({}); + }); +}); diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts index f29b7ae0b..4bb50960b 100644 --- a/lambdas/api-handler/src/utils/validation.ts +++ b/lambdas/api-handler/src/utils/validation.ts @@ -16,3 +16,7 @@ export function assertNotEmpty( return value; } + +export function lowerCaseKeys(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); +} diff --git a/postman/Sandbox.postman_collection.json b/postman/Sandbox.postman_collection.json index ed0e6e3f9..b4aabdb2c 100644 --- a/postman/Sandbox.postman_collection.json +++ b/postman/Sandbox.postman_collection.json @@ -641,7 +641,7 @@ } } ], - "name": "200 - PatchLetters", + "name": "200 - PatchLetter", "request": { "body": { "mode": "raw", @@ -745,7 +745,7 @@ "body": null, "cookie": [], "header": null, - "name": "200 - PatchLetters-CANCELLED", + "name": "200 - PatchLetter-CANCELLED", "originalRequest": { "body": { "mode": "raw", diff --git a/sandbox/api/openapi.yaml b/sandbox/api/openapi.yaml index 2390ee078..923e0976a 100644 --- a/sandbox/api/openapi.yaml +++ b/sandbox/api/openapi.yaml @@ -213,7 +213,7 @@ paths: value: errors: - code: NOTIFY_INVALID_REQUEST - detail: "Invalid Request: Only 'limit' query parameter is supported" + detail: "Only 'limit' query parameter is supported" id: rrt-1931948104716186917-c-geu2-10664-3111479-3.0 links: about: https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier @@ -223,7 +223,7 @@ paths: value: errors: - code: NOTIFY_INVALID_REQUEST - detail: "Invalid Request: limit parameter must be a positive\ + detail: "The limit parameter must be a positive\ \ number not greater than 2500" id: rrt-1931948104716186917-c-geu2-10664-3111479-3.0 links: @@ -609,7 +609,7 @@ paths: patch: description: Update the status of a letter by providing the new status in the request body. - operationId: patchLetters + operationId: patchLetter parameters: - description: "Unique request identifier, in the format of a GUID" explode: false @@ -755,7 +755,7 @@ paths: id: "123654789" type: Letter schema: - $ref: "#/components/schemas/patchLetters_request" + $ref: "#/components/schemas/patchLetter_request" required: true responses: "200": @@ -1183,7 +1183,7 @@ components: - specificationId - status type: object - patchLetters_request: + patchLetter_request: properties: data: $ref: "#/components/schemas/postLetters_request_data_inner" diff --git a/sandbox/controllers/LetterController.js b/sandbox/controllers/LetterController.js index fdf99d8ae..d1954fac8 100644 --- a/sandbox/controllers/LetterController.js +++ b/sandbox/controllers/LetterController.js @@ -16,8 +16,8 @@ const listLetters = async (request, response) => { await Controller.handleRequest(request, response, service.listLetters); }; -const patchLetters = async (request, response) => { - await Controller.handleRequest(request, response, service.patchLetters); +const patchLetter = async (request, response) => { + await Controller.handleRequest(request, response, service.patchLetter); }; const postLetters = async (request, response) => { @@ -28,6 +28,6 @@ const postLetters = async (request, response) => { module.exports = { getLetterStatus, listLetters, - patchLetters, + patchLetter, postLetters, }; diff --git a/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json b/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json index 58277a5a8..dfb7d3b2a 100644 --- a/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json +++ b/sandbox/data/examples/errors/responses/getLetter/limitInvalidValue.json @@ -2,7 +2,7 @@ "errors": [ { "code": "NOTIFY_INVALID_REQUEST", - "detail": "Invalid Request: limit parameter must be a positive number not greater than 2500", + "detail": "The limit parameter must be a positive number not greater than 2500", "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", "links": { "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier" diff --git a/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json b/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json index 036b8f56c..ba1562288 100644 --- a/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json +++ b/sandbox/data/examples/errors/responses/getLetter/unknownParameter.json @@ -2,7 +2,7 @@ "errors": [ { "code": "NOTIFY_INVALID_REQUEST", - "detail": "Invalid Request: Only 'limit' query parameter is supported", + "detail": "Only 'limit' query parameter is supported", "id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", "links": { "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier" diff --git a/sandbox/services/LetterService.js b/sandbox/services/LetterService.js index f964f5c7a..92e8f9ee4 100644 --- a/sandbox/services/LetterService.js +++ b/sandbox/services/LetterService.js @@ -74,14 +74,14 @@ const listLetters = ({ xRequestId, xCorrelationId, limit = 10 }) => new Promise( * * xRequestId String Unique request identifier, in the format of a GUID * id String Unique identifier of this resource -* patchLettersRequest PatchLettersRequest +* patchLetterRequest PatchLetterRequest * xCorrelationId String An optional ID which you can use to track transactions across multiple systems. It can take any value, but we recommend avoiding `.` characters. If not provided in the request, NHS Notify will default to a system generated ID in its place. The ID will be returned in a response header. (optional) * returns getLetterStatus_200_response * */ -const patchLetters = ({ xRequestId, id, body, xCorrelationId }) => new Promise( +const patchLetter = ({ xRequestId, id, body, xCorrelationId }) => new Promise( async (resolve, reject) => { try { - const responseData = await ResponseProvider.patchLettersResponse(body); + const responseData = await ResponseProvider.patchLetterResponse(body); const content = await fs.readFile(responseData.responsePath); const fileData = JSON.parse(content); @@ -131,6 +131,6 @@ const postLetters = ({ xRequestId, body, xCorrelationId }) => new Promise( module.exports = { getLetterStatus, listLetters, - patchLetters, + patchLetter, postLetters, }; diff --git a/sandbox/utils/ResponseProvider.js b/sandbox/utils/ResponseProvider.js index 48fcaf9c6..746f66076 100644 --- a/sandbox/utils/ResponseProvider.js +++ b/sandbox/utils/ResponseProvider.js @@ -74,8 +74,8 @@ async function getLettersResponse(limit) { return mapExampleGetResponse(status, getLettersfileMap); } -async function patchLettersResponse(request) { - const patchLettersFileMap = { +async function patchLetterResponse(request) { + const patchLetterFileMap = { 'data/examples/patchLetter/requests/patchLetter_DEFAULT.json': {responsePath: 'data/examples/patchLetter/responses/patchLetter_PENDING.json', responseCode: 200}, 'data/examples/patchLetter/requests/patchLetter_PENDING.json': {responsePath:'data/examples/patchLetter/responses/patchLetter_PENDING.json',responseCode: 200}, 'data/examples/patchLetter/requests/patchLetter_ACCEPTED.json': {responsePath:'data/examples/patchLetter/responses/patchLetter_ACCEPTED.json',responseCode: 200}, @@ -90,14 +90,14 @@ async function patchLettersResponse(request) { 'data/examples/patchLetter/requests/patchLetter_INVALID.json': {responsePath:'data/examples/errors/responses/badRequest.json',responseCode: 400}, 'data/examples/patchLetter/requests/patchLetter_NOTFOUND.json': {responsePath:'data/examples/errors/responses/resourceNotFound.json',responseCode: 404}, }; - return await mapExampleResponse(request, patchLettersFileMap); + return await mapExampleResponse(request, patchLetterFileMap); } async function postLettersResponse(request) { - const patchLettersFileMap = { + const patchLetterFileMap = { 'data/examples/postLetter/requests/postLetters.json': {responsePath: 'data/examples/postLetter/responses/postLetters.json', responseCode: 200}, }; - return await mapExampleResponse(request, patchLettersFileMap); + return await mapExampleResponse(request, patchLetterFileMap); } async function postMIResponse(request) { @@ -120,7 +120,7 @@ async function getLetterDataResponse(id) { module.exports = { getLetterStatusResponse, getLettersResponse, - patchLettersResponse, + patchLetterResponse, postMIResponse, getLetterDataResponse, postLettersResponse diff --git a/specification/api/components/endpoints/patchLetter.yml b/specification/api/components/endpoints/patchLetter.yml index 6deaca9d6..411d1b64e 100644 --- a/specification/api/components/endpoints/patchLetter.yml +++ b/specification/api/components/endpoints/patchLetter.yml @@ -1,6 +1,6 @@ summary: Update the status of a letter description: Update the status of a letter by providing the new status in the request body. -operationId: patchLetters +operationId: patchLetter requestBody: $ref: "../requests/patchLetterRequest.yml" responses: