Skip to content

Commit 077a43a

Browse files
committed
Merge branch 'main' into feature/CCM-12352-refactor-helpers-and-datastore
# Conflicts: # lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts # lambdas/api-handler/src/mappers/letter-mapper.ts
2 parents 1313be5 + 6a95229 commit 077a43a

File tree

15 files changed

+487
-24
lines changed

15 files changed

+487
-24
lines changed

infrastructure/terraform/components/api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ No requirements.
3434
|------|--------|---------|
3535
| <a name="module_authorizer_lambda"></a> [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
3636
| <a name="module_domain_truststore"></a> [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a |
37+
| <a name="module_get_letter"></a> [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
3738
| <a name="module_get_letter_data"></a> [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
3839
| <a name="module_get_letters"></a> [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
3940
| <a name="module_kms"></a> [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a |

infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
4949

5050
resources = [
5151
module.authorizer_lambda.function_arn,
52+
module.get_letter.function_arn,
5253
module.get_letters.function_arn,
5354
module.patch_letter.function_arn,
5455
module.get_letter_data.function_arn

infrastructure/terraform/components/api/locals.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ locals {
88
APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn
99
AWS_REGION = var.region
1010
AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
11+
GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn
1112
GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
1213
GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn
1314
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
module "get_letter" {
2+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip"
3+
4+
function_name = "get_letter"
5+
description = "Get letter status"
6+
7+
aws_account_id = var.aws_account_id
8+
component = var.component
9+
environment = var.environment
10+
project = var.project
11+
region = var.region
12+
group = var.group
13+
14+
log_retention_in_days = var.log_retention_in_days
15+
kms_key_arn = module.kms.key_arn
16+
17+
iam_policy_document = {
18+
body = data.aws_iam_policy_document.get_letter_lambda.json
19+
}
20+
21+
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
22+
function_code_base_path = local.aws_lambda_functions_dir_path
23+
function_code_dir = "api-handler/dist"
24+
function_include_common = true
25+
handler_function_name = "getLetter"
26+
runtime = "nodejs22.x"
27+
memory = 128
28+
timeout = 5
29+
log_level = var.log_level
30+
31+
force_lambda_code_deploy = var.force_lambda_code_deploy
32+
enable_lambda_insights = false
33+
34+
send_to_firehose = true
35+
log_destination_arn = local.destination_arn
36+
log_subscription_role_arn = local.acct.log_subscription_role_arn
37+
38+
lambda_env_vars = merge(local.common_lambda_env_vars, {})
39+
}
40+
41+
data "aws_iam_policy_document" "get_letter_lambda" {
42+
statement {
43+
sid = "KMSPermissions"
44+
effect = "Allow"
45+
46+
actions = [
47+
"kms:Decrypt",
48+
"kms:GenerateDataKey",
49+
]
50+
51+
resources = [
52+
module.kms.key_arn, ## Requires shared kms module
53+
]
54+
}
55+
56+
statement {
57+
sid = "AllowDynamoDBAccess"
58+
effect = "Allow"
59+
60+
actions = [
61+
"dynamodb:GetItem",
62+
"dynamodb:Query"
63+
]
64+
65+
resources = [
66+
aws_dynamodb_table.letters.arn
67+
]
68+
}
69+
}

infrastructure/terraform/components/api/resources/spec.tmpl.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@
5454
}
5555
},
5656
"/letters/{id}": {
57+
"get": {
58+
"description": "Returns 200 OK with letter status.",
59+
"responses": {
60+
"200": {
61+
"description": "OK"
62+
},
63+
"400": {
64+
"description": "Bad request, invalid input data"
65+
},
66+
"404": {
67+
"description": "Resource not found"
68+
},
69+
"500": {
70+
"description": "Server error"
71+
}
72+
},
73+
"security": [
74+
{
75+
"LambdaAuthorizer": []
76+
}
77+
],
78+
"summary": "Get letter",
79+
"x-amazon-apigateway-integration": {
80+
"contentHandling": "CONVERT_TO_TEXT",
81+
"credentials": "${APIG_EXECUTION_ROLE_ARN}",
82+
"httpMethod": "POST",
83+
"passthroughBehavior": "WHEN_NO_TEMPLATES",
84+
"responses": {
85+
".*": {
86+
"statusCode": "200"
87+
}
88+
},
89+
"timeoutInMillis": 29000,
90+
"type": "AWS_PROXY",
91+
"uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_LAMBDA_ARN}/invocations"
92+
}
93+
},
5794
"parameters": [
5895
{
5996
"description": "Unique identifier of this resource",

lambdas/api-handler/src/contracts/letters.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const PatchLetterRequestResourceSchema = z.object({
3636
}).strict()
3737
}).strict();
3838

39-
export const PatchLetterResponseResourceSchema = z.object({
39+
export const GetLetterResponseResourceSchema = z.object({
4040
id: z.string(),
4141
type: z.literal('Letter'),
4242
attributes: z.object({
@@ -58,12 +58,16 @@ export const GetLettersResponseResourceSchema = z.object({
5858
}).strict()
5959
}).strict();
6060

61+
export const PatchLetterResponseResourceSchema = GetLetterResponseResourceSchema;
62+
6163
export type LetterStatus = z.infer<typeof LetterStatusSchema>;
6264

6365
export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema);
64-
export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);
66+
export const GetLetterResponseSchema = makeDocumentSchema(GetLetterResponseResourceSchema);
6567
export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema);
68+
export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);
6669

6770
export type PatchLetterRequest = z.infer<typeof PatchLetterRequestSchema>;
68-
export type PatchLetterResponse = z.infer<typeof PatchLetterResponseSchema>;
71+
export type GetLetterResponse = z.infer<typeof GetLetterResponseSchema>;
6972
export type GetLettersResponse = z.infer<typeof GetLettersResponseSchema>;
73+
export type PatchLetterResponse = z.infer<typeof PatchLetterResponseSchema>;
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Context } from 'aws-lambda';
2+
import { mockDeep } from 'jest-mock-extended';
3+
import * as letterService from '../../services/letter-operations';
4+
import { makeApiGwEvent } from './utils/test-utils';
5+
import { ApiErrorDetail } from '../../contracts/errors';
6+
import { NotFoundError } from '../../errors';
7+
import { S3Client } from '@aws-sdk/client-s3';
8+
import pino from 'pino';
9+
import { LetterRepository } from '../../../../../internal/datastore/src';
10+
import { Deps } from '../../config/deps';
11+
import { EnvVars } from '../../config/env';
12+
import { createGetLetterHandler } from '../get-letter';
13+
14+
jest.mock('../../services/letter-operations');
15+
16+
17+
describe('API Lambda handler', () => {
18+
19+
const mockedDeps: jest.Mocked<Deps> = {
20+
s3Client: {} as unknown as S3Client,
21+
letterRepo: {} as unknown as LetterRepository,
22+
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
23+
env: {
24+
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
25+
APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
26+
LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME',
27+
LETTER_TTL_HOURS: 12960,
28+
DOWNLOAD_URL_TTL_SECONDS: 60,
29+
MAX_LIMIT: 2500
30+
} as unknown as EnvVars
31+
};
32+
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
jest.resetModules();
36+
});
37+
38+
it('returns 200 OK and the letter status', async () => {
39+
40+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
41+
mockedGetLetterById.mockResolvedValue({
42+
id: 'id1',
43+
specificationId: 'spec1',
44+
groupId: 'group1',
45+
status: 'PENDING'
46+
});
47+
48+
const event = makeApiGwEvent({path: '/letters/id1',
49+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
50+
pathParameters: {id: 'id1'}});
51+
52+
const getLetter = createGetLetterHandler(mockedDeps);
53+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
54+
55+
const expected = {
56+
data: {
57+
id: 'id1',
58+
type: 'Letter',
59+
attributes: {
60+
status: 'PENDING',
61+
specificationId: 'spec1',
62+
groupId: 'group1'
63+
}
64+
}
65+
};
66+
67+
expect(result).toEqual({
68+
statusCode: 200,
69+
body: JSON.stringify(expected, null, 2),
70+
});
71+
});
72+
73+
it('includes the reason code and reason text if present', async () => {
74+
75+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
76+
mockedGetLetterById.mockResolvedValue({
77+
id: 'id1',
78+
specificationId: 'spec1',
79+
groupId: 'group1',
80+
status: 'FAILED',
81+
reasonCode: 100,
82+
reasonText: 'failed validation'
83+
});
84+
85+
const event = makeApiGwEvent({path: '/letters/id1',
86+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
87+
pathParameters: {id: 'id1'}});
88+
89+
const getLetter = createGetLetterHandler(mockedDeps);
90+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
91+
92+
const expected = {
93+
data: {
94+
id: 'id1',
95+
type: 'Letter',
96+
attributes: {
97+
status: 'FAILED',
98+
specificationId: 'spec1',
99+
groupId: 'group1',
100+
reasonCode: 100,
101+
reasonText: 'failed validation'
102+
}
103+
}
104+
};
105+
106+
expect(result).toEqual({
107+
statusCode: 200,
108+
body: JSON.stringify(expected, null, 2),
109+
});
110+
});
111+
112+
it('returns 404 Not Found when letter matching id is not found', async () => {
113+
114+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
115+
mockedGetLetterById.mockImplementation(() => {
116+
throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
117+
});
118+
119+
const event = makeApiGwEvent({path: '/letters/id1',
120+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
121+
pathParameters: {id: 'id1'}});
122+
123+
const getLetter = createGetLetterHandler(mockedDeps);
124+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
125+
126+
expect(result).toEqual(expect.objectContaining({
127+
statusCode: 404,
128+
}));
129+
});
130+
131+
it ('returns 500 when correlation id is missing from header', async() => {
132+
const event = makeApiGwEvent({path: '/letters/id1',
133+
headers: {'nhsd-supplier-id': 'supplier1', 'x-request-id': 'requestId'},
134+
pathParameters: {id: 'id1'}});
135+
136+
const getLetter = createGetLetterHandler(mockedDeps);
137+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
138+
139+
expect(result).toEqual(expect.objectContaining({
140+
statusCode: 500,
141+
}));
142+
});
143+
144+
it ('returns 500 when supplier id is missing from header', async() => {
145+
const event = makeApiGwEvent({path: '/letters/id1',
146+
headers: {'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
147+
pathParameters: {id: 'id1'}});
148+
149+
const getLetter = createGetLetterHandler(mockedDeps);
150+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
151+
152+
expect(result).toEqual(expect.objectContaining({
153+
statusCode: 500,
154+
}));
155+
});
156+
157+
158+
it ('returns 400 when letter id is missing from path', async() => {
159+
const event = makeApiGwEvent({path: '/letters/id1',
160+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}});
161+
162+
const getLetter = createGetLetterHandler(mockedDeps);
163+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
164+
165+
expect(result).toEqual(expect.objectContaining({
166+
statusCode: 400,
167+
}));
168+
});
169+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { APIGatewayProxyHandler } from "aws-lambda";
2+
import { assertNotEmpty, validateCommonHeaders } from "../utils/validation";
3+
import { ValidationError } from "../errors";
4+
import { ApiErrorDetail } from "../contracts/errors";
5+
import { getLetterById } from "../services/letter-operations";
6+
import { mapErrorToResponse } from "../mappers/error-mapper";
7+
import { mapToGetLetterResponse } from "../mappers/letter-mapper";
8+
import { Deps } from "../config/deps";
9+
10+
11+
export function createGetLetterHandler(deps: Deps): APIGatewayProxyHandler {
12+
13+
return async (event) => {
14+
15+
const commonHeadersResult = validateCommonHeaders(event.headers, deps);
16+
17+
if (!commonHeadersResult.ok) {
18+
return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger);
19+
}
20+
21+
try {
22+
const letterId = assertNotEmpty(event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter));
23+
24+
const letter = await getLetterById(commonHeadersResult.value.supplierId, letterId, deps.letterRepo);
25+
26+
const response = mapToGetLetterResponse(letter);
27+
28+
deps.logger.info({
29+
description: 'Letter successfully fetched by id',
30+
supplierId: commonHeadersResult.value.supplierId,
31+
letterId
32+
});
33+
34+
return {
35+
statusCode: 200,
36+
body: JSON.stringify(response, null, 2),
37+
};
38+
} catch (error)
39+
{
40+
return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger);
41+
}
42+
}
43+
}

lambdas/api-handler/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { createDependenciesContainer } from "./config/deps";
2+
import { createGetLetterHandler } from "./handlers/get-letter";
23
import { createGetLetterDataHandler } from "./handlers/get-letter-data";
34
import { createGetLettersHandler } from "./handlers/get-letters";
45
import { createPatchLetterHandler } from "./handlers/patch-letter";
56

67
const container = createDependenciesContainer();
78

9+
export const getLetter = createGetLetterHandler(container);
810
export const getLetterData = createGetLetterDataHandler(container);
911
export const getLetters = createGetLettersHandler(container);
1012
export const patchLetter = createPatchLetterHandler(container);

0 commit comments

Comments
 (0)