Skip to content

Commit 2ac63f3

Browse files
committed
Added get status endpoint for single letter
1 parent e430983 commit 2ac63f3

File tree

16 files changed

+474
-24
lines changed

16 files changed

+474
-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.20/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_letters"></a> [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip | n/a |
3839
| <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 |
3940
| <a name="module_logging_bucket"></a> [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.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
]

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
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
1314
})
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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 { getLetter } from '../../index';
6+
import { ApiErrorDetail } from '../../contracts/errors';
7+
import { NotFoundError } from '../../errors';
8+
9+
jest.mock('../../services/letter-operations');
10+
11+
jest.mock('../../config/lambda-config', () => ({
12+
lambdaConfig: {
13+
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
14+
APIM_CORRELATION_HEADER: 'nhsd-correlation-id'
15+
}
16+
}));
17+
18+
describe('API Lambda handler', () => {
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
jest.resetModules();
23+
});
24+
25+
it('returns 200 OK and the letter status', async () => {
26+
27+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
28+
mockedGetLetterById.mockResolvedValue({
29+
id: 'id1',
30+
specificationId: 'spec1',
31+
groupId: 'group1',
32+
status: 'PENDING'
33+
});
34+
35+
const event = makeApiGwEvent({path: '/letters/id1',
36+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'},
37+
pathParameters: {id: 'id1'}});
38+
39+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
40+
41+
const expected = {
42+
data: {
43+
id: 'id1',
44+
type: 'Letter',
45+
attributes: {
46+
status: 'PENDING',
47+
specificationId: 'spec1',
48+
groupId: 'group1'
49+
}
50+
}
51+
};
52+
53+
expect(result).toEqual({
54+
statusCode: 200,
55+
body: JSON.stringify(expected, null, 2),
56+
});
57+
});
58+
59+
it('includes the reason code and reason text if present', async () => {
60+
61+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
62+
mockedGetLetterById.mockResolvedValue({
63+
id: 'id1',
64+
specificationId: 'spec1',
65+
groupId: 'group1',
66+
status: 'FAILED',
67+
reasonCode: 100,
68+
reasonText: 'failed validation'
69+
});
70+
71+
const event = makeApiGwEvent({path: '/letters/id1',
72+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'},
73+
pathParameters: {id: 'id1'}});
74+
75+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
76+
77+
78+
const expected = {
79+
data: {
80+
id: 'id1',
81+
type: 'Letter',
82+
attributes: {
83+
status: 'FAILED',
84+
specificationId: 'spec1',
85+
groupId: 'group1',
86+
reasonCode: 100,
87+
reasonText: 'failed validation'
88+
}
89+
}
90+
};
91+
92+
expect(result).toEqual({
93+
statusCode: 200,
94+
body: JSON.stringify(expected, null, 2),
95+
});
96+
});
97+
98+
it('returns 404 Not Found when letter matching id is not found', async () => {
99+
100+
const mockedGetLetterById = letterService.getLetterById as jest.Mock;
101+
mockedGetLetterById.mockImplementation(() => {
102+
throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
103+
});
104+
105+
const event = makeApiGwEvent({path: '/letters/id1',
106+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'},
107+
pathParameters: {id: 'id1'}});
108+
109+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
110+
111+
expect(result).toEqual(expect.objectContaining({
112+
statusCode: 404,
113+
}));
114+
});
115+
116+
it ('returns 500 when correlation id is missing from header', async() => {
117+
const event = makeApiGwEvent({path: '/letters/id1',
118+
headers: {'nhsd-supplier-id': 'supplier1'},
119+
pathParameters: {id: 'id1'}});
120+
121+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
122+
123+
expect(result).toEqual(expect.objectContaining({
124+
statusCode: 500,
125+
}));
126+
});
127+
128+
it ('returns 400 when supplier id is missing from header', async() => {
129+
const event = makeApiGwEvent({path: '/letters/id1',
130+
headers: {'nhsd-correlation-id': 'correlationId'},
131+
pathParameters: {id: 'id1'}});
132+
133+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
134+
135+
expect(result).toEqual(expect.objectContaining({
136+
statusCode: 400,
137+
}));
138+
});
139+
140+
141+
it ('returns 400 when letter id is missing from path', async() => {
142+
const event = makeApiGwEvent({path: '/letters/id1',
143+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}});
144+
145+
const result = await getLetter(event, mockDeep<Context>(), jest.fn());
146+
147+
expect(result).toEqual(expect.objectContaining({
148+
statusCode: 400,
149+
}));
150+
});
151+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import pino from "pino";
2+
import { createLetterRepository } from "../infrastructure/letter-repo-factory";
3+
import { APIGatewayProxyHandler } from "aws-lambda";
4+
import { assertNotEmpty, lowerCaseKeys } from "../utils/validation";
5+
import { lambdaConfig } from "../config/lambda-config";
6+
import { ValidationError } from "../errors";
7+
import { ApiErrorDetail } from "../contracts/errors";
8+
import { getLetterById } from "../services/letter-operations";
9+
import { mapErrorToResponse } from "../mappers/error-mapper";
10+
import { mapToGetLetterResponse } from "../mappers/letter-mapper";
11+
12+
13+
const letterRepo = createLetterRepository();
14+
const log = pino();
15+
16+
export const getLetter: APIGatewayProxyHandler = async (event) => {
17+
18+
let correlationId;
19+
try {
20+
assertNotEmpty(event.headers, new Error("The request headers are empty"));
21+
const lowerCasedHeaders = lowerCaseKeys(event.headers);
22+
23+
correlationId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.APIM_CORRELATION_HEADER],
24+
new Error("The request headers don't contain the APIM correlation id"));
25+
26+
const supplierId = assertNotEmpty(lowerCasedHeaders[lambdaConfig.SUPPLIER_ID_HEADER],
27+
new ValidationError(ApiErrorDetail.InvalidRequestMissingSupplierId));
28+
29+
const letterId = assertNotEmpty(event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter));
30+
31+
const letter = await getLetterById(supplierId, letterId, letterRepo);
32+
33+
const response = mapToGetLetterResponse(letter);
34+
35+
log.info({
36+
description: 'Letter successfully fetched by id',
37+
supplierId,
38+
letterId
39+
});
40+
41+
return {
42+
statusCode: 200,
43+
body: JSON.stringify(response, null, 2),
44+
};
45+
} catch (error)
46+
{
47+
return mapErrorToResponse(error, correlationId);
48+
}
49+
}

lambdas/api-handler/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Export all handlers for ease of access
2+
export { getLetter } from './handlers/get-letter';
23
export { getLetters } from './handlers/get-letters';
34
export { patchLetter } from './handlers/patch-letter';

0 commit comments

Comments
 (0)