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: