Skip to content

Commit 829b599

Browse files
Merge remote-tracking branch 'origin/feature/CCM-11602_get_endpoint' into feature/CCM-11188
2 parents 651d163 + b8e4ef2 commit 829b599

File tree

11 files changed

+178
-31
lines changed

11 files changed

+178
-31
lines changed

infrastructure/terraform/components/api/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ No requirements.
1818
| <a name="input_force_lambda_code_deploy"></a> [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no |
1919
| <a name="input_group"></a> [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes |
2020
| <a name="input_kms_deletion_window"></a> [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no |
21+
| <a name="input_letter_table_ttl_hours"></a> [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no |
2122
| <a name="input_log_level"></a> [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no |
2223
| <a name="input_log_retention_in_days"></a> [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no |
2324
| <a name="input_manually_configure_mtls_truststore"></a> [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no |
25+
| <a name="input_max_get_limit"></a> [max\_get\_limit](#input\_max\_get\_limit) | Default limit to apply to GET requests that support pagination | `number` | `2500` | no |
2426
| <a name="input_parent_acct_environment"></a> [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no |
2527
| <a name="input_project"></a> [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
2628
| <a name="input_region"></a> [region](#input\_region) | The AWS Region | `string` | n/a | yes |

infrastructure/terraform/components/api/module_lambda_get_letters.tf

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ module "get_letters" {
3535
log_destination_arn = local.destination_arn
3636
log_subscription_role_arn = local.acct.log_subscription_role_arn
3737

38-
lambda_env_vars = merge(local.common_db_access_lambda_env_vars, {})
38+
lambda_env_vars = merge(local.common_db_access_lambda_env_vars, {
39+
MAX_LIMIT = var.max_get_limit
40+
})
3941
}
4042

4143
data "aws_iam_policy_document" "get_letters_lambda" {

infrastructure/terraform/components/api/variables.tf

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,20 @@ variable "enable_backups" {
9999
default = false
100100
}
101101

102-
103102
variable "ca_pem_filename" {
104103
type = string
105104
description = "Filename for the CA truststore file within the s3 bucket"
106105
default = null
107106
}
107+
108+
variable "letter_table_ttl_hours" {
109+
type = number
110+
description = "Number of hours to set as TTL on letters table"
111+
default = 24
112+
}
113+
114+
variable "max_get_limit" {
115+
type = number
116+
description = "Default limit to apply to GET requests that support pagination"
117+
default = 2500
118+
}

internal/datastore/src/types.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ The schemas are generated from Zod definitions and provide a visual representati
1010
erDiagram
1111
Letter {
1212
string id
13-
string supplierId "ref: Supplier"
13+
string status "enum: PENDING, ACCEPTED, REJECTED, PRINTED, ENCLOSED, CANCELLED, DISPATCHED, FAILED, RETURNED, DESTROYED, FORWARDED, DELIVERED"
1414
string specificationId
1515
string groupId
16+
number reasonCode
17+
string reasonText
18+
string supplierId
1619
string url "url"
17-
string status "enum: PENDING, ACCEPTED, REJECTED, PRINTED, ENCLOSED, CANCELLED, DISPATCHED, FAILED, RETURNED, DESTROYED, FORWARDED, DELIVERED"
1820
string createdAt
1921
string updatedAt
2022
string supplierStatus
23+
string supplierStatusSk
2124
number ttl "min: -9007199254740991, max: 9007199254740991"
2225
}
23-
Supplier {
24-
}
25-
Letter }o--|| Supplier : "supplierId"
2626
```
2727

2828
## MI schema

lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { mockDeep } from 'jest-mock-extended';
44
import { makeApiGwEvent } from './utils/test-utils';
55
import * as letterService from '../../services/letter-operations';
66
import { mapErrorToResponse } from '../../mappers/error-mapper';
7+
import { getEnvars } from '../get-letters';
8+
import { ValidationError } from '../../errors';
9+
import * as errors from '../../contracts/errors';
710

811
jest.mock('../../mappers/error-mapper');
912
const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse);
@@ -13,6 +16,7 @@ const expectedErrorResponse: APIGatewayProxyResult = {
1316
};
1417
mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse);
1518

19+
1620
jest.mock('../../services/letter-operations');
1721

1822
jest.mock("../../config/lambda-config", () => ({
@@ -23,8 +27,21 @@ jest.mock("../../config/lambda-config", () => ({
2327

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

30+
const originalEnv = process.env;
31+
2632
beforeEach(() => {
2733
jest.clearAllMocks();
34+
jest.resetModules();
35+
process.env = { ...originalEnv };
36+
process.env.MAX_LIMIT = '2500';
37+
});
38+
39+
afterEach(() => {
40+
process.env = originalEnv;
41+
});
42+
43+
it('uses process.env.MAX_LIMIT for max limit set', async () => {
44+
expect(getEnvars().maxLimit).toBe(2500);
2845
});
2946

3047
it('returns 200 OK with basic paginated resources', async () => {
@@ -94,10 +111,11 @@ describe('API Lambda handler', () => {
94111
const callback = jest.fn();
95112
const result = await getLetters(event, context, callback);
96113

114+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber));
97115
expect(result).toEqual(expectedErrorResponse);
98116
});
99117

100-
it("returns 400 if the limit parameter is not positive", async () => {
118+
it("returns 400 if the limit parameter is negative", async () => {
101119
const event = makeApiGwEvent({
102120
path: "/letters",
103121
queryStringParameters: { limit: "-1" },
@@ -107,6 +125,49 @@ describe('API Lambda handler', () => {
107125
const callback = jest.fn();
108126
const result = await getLetters(event, context, callback);
109127

128+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange));
129+
expect(result).toEqual(expectedErrorResponse);
130+
});
131+
132+
it("returns 400 if the limit parameter is zero", async () => {
133+
const event = makeApiGwEvent({
134+
path: "/letters",
135+
queryStringParameters: { limit: "0" },
136+
headers: {'nhsd-supplier-id': 'supplier1'}
137+
});
138+
const context = mockDeep<Context>();
139+
const callback = jest.fn();
140+
const result = await getLetters(event, context, callback);
141+
142+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange));
143+
expect(result).toEqual(expectedErrorResponse);
144+
});
145+
146+
it("returns 400 if the limit parameter is higher than max limit", async () => {
147+
const event = makeApiGwEvent({
148+
path: "/letters",
149+
queryStringParameters: { limit: "2501" },
150+
headers: {'nhsd-supplier-id': 'supplier1'}
151+
});
152+
const context = mockDeep<Context>();
153+
const callback = jest.fn();
154+
const result = await getLetters(event, context, callback);
155+
156+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange));
157+
expect(result).toEqual(expectedErrorResponse);
158+
});
159+
160+
it("returns 400 if unknown parameters are present", async () => {
161+
const event = makeApiGwEvent({
162+
path: "/letters",
163+
queryStringParameters: { max: "2000" },
164+
headers: {'nhsd-supplier-id': 'supplier1'}
165+
});
166+
const context = mockDeep<Context>();
167+
const callback = jest.fn();
168+
const result = await getLetters(event, context, callback);
169+
170+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly));
110171
expect(result).toEqual(expectedErrorResponse);
111172
});
112173

@@ -116,6 +177,7 @@ describe('API Lambda handler', () => {
116177
const callback = jest.fn();
117178
const result = await getLetters(event, context, callback);
118179

180+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId));
119181
expect(result).toEqual(expectedErrorResponse);
120182
});
121183

@@ -125,6 +187,7 @@ describe('API Lambda handler', () => {
125187
const callback = jest.fn();
126188
const result = await getLetters(event, context, callback);
127189

190+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingSupplierId));
128191
expect(result).toEqual(expectedErrorResponse);
129192
});
130193
});

lambdas/api-handler/src/handlers/get-letters.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,27 @@ import pino from 'pino';
99
import { mapErrorToResponse } from "../mappers/error-mapper";
1010
import { ValidationError } from "../errors";
1111
import { mapLetterBaseToApiResource } from "../mappers/letter-mapper";
12+
import util from "util";
1213

1314
const letterRepo = createLetterRepository();
15+
1416
const log = pino();
1517

1618
// The endpoint should only return pending letters for now
1719
const status = "PENDING";
1820

21+
export const getEnvars = (): { maxLimit: number } => ({
22+
maxLimit: parseInt(process.env.MAX_LIMIT!)
23+
});
24+
1925
export const getLetters: APIGatewayProxyHandler = async (event) => {
2026

27+
const { maxLimit } = getEnvars();
28+
2129
try {
30+
assertNotEmpty(event.headers, errors.ApiErrorDetail.InvalidRequestMissingSupplierId);
2231
const supplierId = assertNotEmpty(event.headers[lambdaConfig.SUPPLIER_ID_HEADER], errors.ApiErrorDetail.InvalidRequestMissingSupplierId);
23-
const limitNumber = validateLimit(event.queryStringParameters);
32+
const limitNumber = getLimitOrDefault(event.queryStringParameters, maxLimit);
2433

2534
const letters = await getLettersForSupplier(
2635
supplierId,
@@ -51,38 +60,56 @@ export const getLetters: APIGatewayProxyHandler = async (event) => {
5160
}
5261
};
5362

54-
function validateLimit(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null) : number {
63+
function getLimitOrDefault(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null, maxLimit: number) : number {
5564

56-
let limit = queryStringParameters?.limit;
57-
58-
if (!limit) {
59-
limit = "10";
60-
}
61-
62-
let limitNumber = Number(limit);
63-
64-
assertIsNumber(limitNumber, limit);
65-
assertIsPositive(limitNumber, limit);
66-
67-
return limitNumber;
65+
validateLimitParamOnly(queryStringParameters);
66+
return getLimit(queryStringParameters?.limit, maxLimit);
6867
}
6968

70-
function assertIsNumber(limitNumber: number, limit: string) {
69+
function assertIsNumber(limitNumber: number) {
7170
if (isNaN(limitNumber)) {
7271
log.info({
7372
description: "limit parameter is not a number",
74-
limit,
73+
limitNumber,
7574
});
7675
throw new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotANumber);
7776
}
7877
}
7978

80-
function assertIsPositive(limitNumber: number, limit?: string) {
81-
if (limitNumber < 0) {
79+
function assertLimitInRange(limitNumber: number, maxLimit: number) {
80+
if (limitNumber <= 0 || limitNumber > maxLimit) {
8281
log.info({
83-
description: "limit parameter is not positive",
84-
limit,
82+
description: "Limit value is invalid",
83+
limitNumber,
8584
});
86-
throw new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotPositive);
85+
throw new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitNotInRange);
86+
}
87+
}
88+
89+
function validateLimitParamOnly(queryStringParameters: APIGatewayProxyEventQueryStringParameters | null) {
90+
if (
91+
queryStringParameters &&
92+
Object.keys(queryStringParameters).some(
93+
(key) => key !== "limit"
94+
)
95+
) {
96+
log.info({
97+
description: "Unexpected query parameter(s) present",
98+
queryStringParameters: queryStringParameters,
99+
});
100+
throw new ValidationError(errors.ApiErrorDetail.InvalidRequestLimitOnly);
101+
}
102+
}
103+
104+
function getLimit(limit: string | undefined, maxLimit: number) {
105+
let result;
106+
if (limit) {
107+
let limitParam = limit;
108+
result = Number(limitParam);
109+
assertIsNumber(result);
110+
assertLimitInRange(result, maxLimit);
111+
} else {
112+
result = maxLimit;
87113
}
114+
return result;
88115
}

specification/api/components/endpoints/listLetters.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ description: The key use of this endpoint is to query letters which are ready to
1111
responses:
1212
'200':
1313
$ref: "../responses/getLetters200.yml"
14+
'400':
15+
$ref: "../responses/errors/listLetters/listLettersBadRequest.yml"
1416
'404':
1517
$ref: "../responses/errors/resourceNotFound.yml"
1618
'429':
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "NOTIFY_INVALID_REQUEST",
5+
"detail": "Invalid Request: limit parameter must be a positive number not greater than 2500",
6+
"id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0",
7+
"links": {
8+
"about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier"
9+
},
10+
"status": "400",
11+
"title": "Invalid request"
12+
}
13+
]
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "NOTIFY_INVALID_REQUEST",
5+
"detail": "Invalid Request: Only 'limit' query parameter is supported",
6+
"id": "rrt-1931948104716186917-c-geu2-10664-3111479-3.0",
7+
"links": {
8+
"about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier"
9+
},
10+
"status": "400",
11+
"title": "Invalid request"
12+
}
13+
]
14+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: limit
22
in: query
33
description: |-
4-
The maximum number of items to return in a single request
4+
The maximum number of items to return in a single request, max 2500
55
schema:
66
type: number
7-
example: 100
7+
example: 2500

0 commit comments

Comments
 (0)