Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions infrastructure/terraform/components/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ No requirements.
|------|--------|---------|
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {

resources = [
module.authorizer_lambda.function_arn,
module.get_letter.function_arn,
module.get_letters.function_arn,
module.patch_letter.function_arn,
module.get_letter_data.function_arn
Expand Down
1 change: 1 addition & 0 deletions infrastructure/terraform/components/api/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ locals {
APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn
AWS_REGION = var.region
AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn
GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
module "get_letter" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip"

function_name = "get_letter"
description = "Get letter status"

aws_account_id = var.aws_account_id
component = var.component
environment = var.environment
project = var.project
region = var.region
group = var.group

log_retention_in_days = var.log_retention_in_days
kms_key_arn = module.kms.key_arn

iam_policy_document = {
body = data.aws_iam_policy_document.get_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 = "getLetter"
runtime = "nodejs22.x"
memory = 128
timeout = 5
log_level = var.log_level

force_lambda_code_deploy = var.force_lambda_code_deploy
enable_lambda_insights = false

send_to_firehose = true
log_destination_arn = local.destination_arn
log_subscription_role_arn = local.acct.log_subscription_role_arn

lambda_env_vars = merge(local.common_lambda_env_vars, {})
}

data "aws_iam_policy_document" "get_letter_lambda" {
statement {
sid = "KMSPermissions"
effect = "Allow"

actions = [
"kms:Decrypt",
"kms:GenerateDataKey",
]

resources = [
module.kms.key_arn, ## Requires shared kms module
]
}

statement {
sid = "AllowDynamoDBAccess"
effect = "Allow"

actions = [
"dynamodb:GetItem",
"dynamodb:Query"
]

resources = [
aws_dynamodb_table.letters.arn
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@
}
},
"/letters/{id}": {
"get": {
"description": "Returns 200 OK with letter status.",
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad request, invalid input data"
},
"404": {
"description": "Resource not found"
},
"500": {
"description": "Server error"
}
},
"security": [
{
"LambdaAuthorizer": []
}
],
"summary": "Get letter",
"x-amazon-apigateway-integration": {
"contentHandling": "CONVERT_TO_TEXT",
"credentials": "${APIG_EXECUTION_ROLE_ARN}",
"httpMethod": "POST",
"passthroughBehavior": "WHEN_NO_TEMPLATES",
"responses": {
".*": {
"statusCode": "200"
}
},
"timeoutInMillis": 29000,
"type": "AWS_PROXY",
"uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_LAMBDA_ARN}/invocations"
}
},
"parameters": [
{
"description": "Unique identifier of this resource",
Expand Down
10 changes: 7 additions & 3 deletions lambdas/api-handler/src/contracts/letters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const PatchLetterRequestResourceSchema = z.object({
}).strict()
}).strict();

export const PatchLetterResponseResourceSchema = z.object({
export const GetLetterResponseResourceSchema = z.object({
id: z.string(),
type: z.literal('Letter'),
attributes: z.object({
Expand All @@ -58,12 +58,16 @@ export const GetLettersResponseResourceSchema = z.object({
}).strict()
}).strict();

export const PatchLetterResponseResourceSchema = GetLetterResponseResourceSchema;

export type LetterStatus = z.infer<typeof LetterStatusSchema>;

export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema);
export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);
export const GetLetterResponseSchema = makeDocumentSchema(GetLetterResponseResourceSchema);
export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema);
export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);

export type PatchLetterRequest = z.infer<typeof PatchLetterRequestSchema>;
export type PatchLetterResponse = z.infer<typeof PatchLetterResponseSchema>;
export type GetLetterResponse = z.infer<typeof GetLetterResponseSchema>;
export type GetLettersResponse = z.infer<typeof GetLettersResponseSchema>;
export type PatchLetterResponse = z.infer<typeof PatchLetterResponseSchema>;
169 changes: 169 additions & 0 deletions lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Context } from 'aws-lambda';
import { mockDeep } from 'jest-mock-extended';
import * as letterService from '../../services/letter-operations';
import { makeApiGwEvent } from './utils/test-utils';
import { ApiErrorDetail } from '../../contracts/errors';
import { NotFoundError } from '../../errors';
import { S3Client } from '@aws-sdk/client-s3';
import pino from 'pino';
import { LetterRepository } from '../../../../../internal/datastore/src';
import { Deps } from '../../config/deps';
import { EnvVars } from '../../config/env';
import { createGetLetterHandler } from '../get-letter';

jest.mock('../../services/letter-operations');


describe('API Lambda handler', () => {

const mockedDeps: jest.Mocked<Deps> = {
s3Client: {} as unknown as S3Client,
letterRepo: {} as unknown as LetterRepository,
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
env: {
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME',
LETTER_TTL_HOURS: 12960,
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500
} as unknown as EnvVars
};

beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});

it('returns 200 OK and the letter status', async () => {

const mockedGetLetterById = letterService.getLetterById as jest.Mock;
mockedGetLetterById.mockResolvedValue({
id: 'id1',
specificationId: 'spec1',
groupId: 'group1',
status: 'PENDING'
});

const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
pathParameters: {id: 'id1'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

const expected = {
data: {
id: 'id1',
type: 'Letter',
attributes: {
status: 'PENDING',
specificationId: 'spec1',
groupId: 'group1'
}
}
};

expect(result).toEqual({
statusCode: 200,
body: JSON.stringify(expected, null, 2),
});
});

it('includes the reason code and reason text if present', async () => {

const mockedGetLetterById = letterService.getLetterById as jest.Mock;
mockedGetLetterById.mockResolvedValue({
id: 'id1',
specificationId: 'spec1',
groupId: 'group1',
status: 'FAILED',
reasonCode: 100,
reasonText: 'failed validation'
});

const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
pathParameters: {id: 'id1'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

const expected = {
data: {
id: 'id1',
type: 'Letter',
attributes: {
status: 'FAILED',
specificationId: 'spec1',
groupId: 'group1',
reasonCode: 100,
reasonText: 'failed validation'
}
}
};

expect(result).toEqual({
statusCode: 200,
body: JSON.stringify(expected, null, 2),
});
});

it('returns 404 Not Found when letter matching id is not found', async () => {

const mockedGetLetterById = letterService.getLetterById as jest.Mock;
mockedGetLetterById.mockImplementation(() => {
throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
});

const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
pathParameters: {id: 'id1'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

expect(result).toEqual(expect.objectContaining({
statusCode: 404,
}));
});

it ('returns 500 when correlation id is missing from header', async() => {
const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-supplier-id': 'supplier1', 'x-request-id': 'requestId'},
pathParameters: {id: 'id1'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

expect(result).toEqual(expect.objectContaining({
statusCode: 500,
}));
});

it ('returns 500 when supplier id is missing from header', async() => {
const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
pathParameters: {id: 'id1'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

expect(result).toEqual(expect.objectContaining({
statusCode: 500,
}));
});


it ('returns 400 when letter id is missing from path', async() => {
const event = makeApiGwEvent({path: '/letters/id1',
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}});

const getLetter = createGetLetterHandler(mockedDeps);
const result = await getLetter(event, mockDeep<Context>(), jest.fn());

expect(result).toEqual(expect.objectContaining({
statusCode: 400,
}));
});
});
43 changes: 43 additions & 0 deletions lambdas/api-handler/src/handlers/get-letter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { APIGatewayProxyHandler } from "aws-lambda";
import { assertNotEmpty, validateCommonHeaders } from "../utils/validation";
import { ValidationError } from "../errors";
import { ApiErrorDetail } from "../contracts/errors";
import { getLetterById } from "../services/letter-operations";
import { mapErrorToResponse } from "../mappers/error-mapper";
import { mapToGetLetterResponse } from "../mappers/letter-mapper";
import { Deps } from "../config/deps";


export function createGetLetterHandler(deps: Deps): APIGatewayProxyHandler {

return async (event) => {

const commonHeadersResult = validateCommonHeaders(event.headers, deps);

if (!commonHeadersResult.ok) {
return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger);
}

try {
const letterId = assertNotEmpty(event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter));

const letter = await getLetterById(commonHeadersResult.value.supplierId, letterId, deps.letterRepo);

const response = mapToGetLetterResponse(letter);

deps.logger.info({
description: 'Letter successfully fetched by id',
supplierId: commonHeadersResult.value.supplierId,
letterId
});

return {
statusCode: 200,
body: JSON.stringify(response, null, 2),
};
} catch (error)
{
return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger);
}
}
}
2 changes: 2 additions & 0 deletions lambdas/api-handler/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createDependenciesContainer } from "./config/deps";
import { createGetLetterHandler } from "./handlers/get-letter";
import { createGetLetterDataHandler } from "./handlers/get-letter-data";
import { createGetLettersHandler } from "./handlers/get-letters";
import { createPatchLetterHandler } from "./handlers/patch-letter";

const container = createDependenciesContainer();

export const getLetter = createGetLetterHandler(container);
export const getLetterData = createGetLetterDataHandler(container);
export const getLetters = createGetLettersHandler(container);
export const patchLetter = createPatchLetterHandler(container);
Loading
Loading