Skip to content

Commit f7c03eb

Browse files
simonlabarerenhsd-david-wassaidenvaines-cgisidnhsm-houston
authored
CCM-11602: Get Letters endpoint (#135)
* CCM-11602: Test * CCM-11602: letters schema * CCM-11602: letters schema * CCM-11602: Give get letters access to GSI * CCM-11602: GET Letters endpoint * CCM-11602: GET Letters endpoint * CCM-11602: GET Letters endpoint * CCM-11602: GET Letters endpoint * CCM-11602: GET Letters endpoint * CCM-11602: GET Endpoint sandbox updates * CCM-11602: Get enpoint tests * CCM-11602: Get enpoint safe header check * CCM-11602: Update sandbox * CCM-11602: Update sandbox * CCM-11602: Add logging * CCM-11602: Update sandbox * CCM-11602: Rename size parameter to limit * CCM-11602: Add validation for limit parameter * CCM-11602: increase Trivy scan timeout * CCM-11602: Add groupId * Correct target URL for main (#134) * CCM-11942 Fixing cross repo workflows (#137) * CCM-11942 Fixing cross repo workflows * CCM-11942 Fixing cross repo workflows * CCM-11942 Fixing cross repo workflows * CCM-11751: Use github release assests for shared modules call (#142) * CCM-11751: Use github release assests for shared modules call * CCM-11751: Use github release assests for shared modules call * CCM-11751: Use github release assests for shared modules call * CCM-11751: Use github release assests for shared modules call * CCM-11751: Fixing workflow trigger (#146) * CCM-11580: Updates for OAS Spec V1 (#139) * CCM-11580: OAS syntax fixes Remove 'requestedProductionStatus' from letter-related JSON schemas and update examples to singular form CCM-11580: Update serve-oas command CCM-11580: Update serve-oas command CCM-11580: Remove unneeded endpoints and resource types + Move getData endpoint under letter CCM-11580: Remove hello world endpoint CCM-11580: Remove hello world endpoint CCM-11580: Add sandbox handler metadata to endpoints CCM-11580: More fixes to examples syntax CCM-11580: Remove remaining reference to hello-world lambda CCM-11580: Remove remaining reference to hello-world lambda CCM-11580: add limit parameter to list letters endpoint CCM-11580: Update sandbox endpoints to match OAS CCM-11580: Update letter responses in Sandbox to match spec changes CCM-11580: implement get letter status endpoint in sandbox Add groupId to schema and examples * CCM-11580: remove x-eov extension from non sandox spec --------- Co-authored-by: mark.slowey1 <[email protected]> * Update README.md (#140) Small change to fix Sonar cloud badges * Add timestamp value to supplierStatusSk * CCM-11602: param validation, max range, and OAS 400 examples * CCM-11602: unit test expectation wording * CCM-11602: MAX LIMIT envar and added into test context * CCM-11602: limit can't be 0, return maxLimit value --------- Co-authored-by: David Wass <[email protected]> Co-authored-by: Aiden Vaines <[email protected]> Co-authored-by: sidnhs <[email protected]> Co-authored-by: Mike Houston <[email protected]> Co-authored-by: mark.slowey1 <[email protected]> Co-authored-by: Tim Ireland <[email protected]> Co-authored-by: Mark Slowey <[email protected]> Co-authored-by: Francisco Videira <[email protected]>
1 parent 3b7b924 commit f7c03eb

File tree

23 files changed

+483
-120
lines changed

23 files changed

+483
-120
lines changed

.github/workflows/stage-1-commit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ jobs:
149149
trivy:
150150
name: "Trivy Scan"
151151
runs-on: ubuntu-latest
152-
timeout-minutes: 5
152+
timeout-minutes: 10
153153
needs: detect-terraform-changes
154154
if: needs.detect-terraform-changes.outputs.terraform_changed == 'true'
155155
steps:

infrastructure/terraform/components/api/README.md

Lines changed: 2 additions & 2 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 |
@@ -31,8 +33,6 @@ No requirements.
3133
|------|--------|---------|
3234
| <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 |
3335
| <a name="module_domain_truststore"></a> [domain\_truststore](#module\_domain\_truststore) | git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/s3bucket | v2.0.17 |
34-
| <a name="module_get_letters"></a> [get\_letters](#module\_get\_letters) | git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/lambda | v2.0.10 |
35-
| <a name="module_kms"></a> [kms](#module\_kms) | git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/kms | v2.0.10 |
3636
| <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 |
3737
| <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 |
3838
| <a name="module_logging_bucket"></a> [logging\_bucket](#module\_logging\_bucket) | git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/s3bucket | v2.0.17 |

infrastructure/terraform/components/api/ddb_table_letters.tf

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ resource "aws_dynamodb_table" "letters" {
1313
global_secondary_index {
1414
name = "supplierStatus-index"
1515
hash_key = "supplierStatus"
16-
range_key = "id"
16+
range_key = "supplierStatusSk"
1717
projection_type = "ALL"
1818
}
1919

@@ -32,6 +32,11 @@ resource "aws_dynamodb_table" "letters" {
3232
type = "S"
3333
}
3434

35+
attribute {
36+
name = "supplierStatusSk"
37+
type = "S"
38+
}
39+
3540
point_in_time_recovery {
3641
enabled = true
3742
}

infrastructure/terraform/components/api/module_lambda_get_letters.tf

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ module "get_letters" {
3737

3838
lambda_env_vars = {
3939
LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
40-
LETTER_TTL_HOURS = 24
40+
LETTER_TTL_HOURS = var.letter_table_ttl_hours,
41+
MAX_LIMIT = var.max_get_limit,
4142
}
4243
}
4344

@@ -69,6 +70,7 @@ data "aws_iam_policy_document" "get_letters_lambda" {
6970

7071
resources = [
7172
aws_dynamodb_table.letters.arn,
73+
"${aws_dynamodb_table.letters.arn}/index/supplierStatus-index"
7274
]
7375
}
7476
}

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/__test__/db.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export async function createTables(context: DBContext) {
5959
IndexName: 'supplierStatus-index',
6060
KeySchema: [
6161
{ AttributeName: 'supplierStatus', KeyType: 'HASH' }, // Partition key for GSI
62-
{ AttributeName: 'id', KeyType: 'RANGE' } // Sort key for GSI
62+
{ AttributeName: 'supplierStatusSk', KeyType: 'RANGE' } // Sort key for GSI
6363
],
6464
Projection: {
6565
ProjectionType: 'ALL'
@@ -69,7 +69,8 @@ export async function createTables(context: DBContext) {
6969
AttributeDefinitions: [
7070
{ AttributeName: 'supplierId', AttributeType: 'S' },
7171
{ AttributeName: 'id', AttributeType: 'S' },
72-
{ AttributeName: 'supplierStatus', AttributeType: 'S' }
72+
{ AttributeName: 'supplierStatus', AttributeType: 'S' },
73+
{ AttributeName: 'supplierStatusSk', AttributeType: 'S' },
7374
]
7475
}));
7576

internal/datastore/src/__test__/letter-repository.test.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Logger } from 'pino';
55
import { createTestLogger, LogStream } from './logs';
66
import { PutCommand } from '@aws-sdk/lib-dynamodb';
77

8-
function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit<Letter, 'ttl' | 'supplierStatus'> {
8+
function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit<Letter, 'ttl' | 'supplierStatus' | 'supplierStatusSk'> {
99
return {
1010
id: letterId,
1111
supplierId: supplierId,
@@ -205,6 +205,7 @@ describe('LetterRepository', () => {
205205
url: 's3://bucket/invalid-letter.pdf',
206206
status: 'PENDING',
207207
supplierStatus: 'supplier1#PENDING',
208+
supplierStatusSk: Date.now().toString(),
208209
createdAt: new Date().toISOString(),
209210
updatedAt: new Date().toISOString()
210211
}
@@ -218,30 +219,48 @@ describe('LetterRepository', () => {
218219
expect(logStream.logs).toContainEqual(expect.stringMatching(/.*specificationId.*Invalid input: expected string.*/));
219220
});
220221

221-
test('should return all letter ids for a supplier', async () => {
222-
await letterRepository.putLetter(createLetter('supplier1', 'letter1'));
223-
await letterRepository.putLetter(createLetter('supplier1', 'letter2'));
224-
await letterRepository.putLetter(createLetter('supplier1', 'letter3'));
225-
await letterRepository.putLetter(createLetter('supplier2', 'letter4'));
226-
await letterRepository.putLetter(createLetter('supplier2', 'letter5'));
227-
228-
const letters = await letterRepository.getLetterIdsBySupplier('supplier1');
229-
expect(letters).toEqual(['letter1', 'letter2', 'letter3']);
230-
});
231-
232-
test('should return empty if no letters exist for a supplier', async () => {
233-
await letterRepository.putLetter(createLetter('supplier1', 'letter1'));
234-
await letterRepository.putLetter(createLetter('supplier1', 'letter2'));
222+
test("should return all letters for a supplier status", async () => {
223+
await letterRepository.putLetter(createLetter("supplier1", "letter1"));
224+
await letterRepository.putLetter(createLetter("supplier1", "letter2"));
225+
await letterRepository.putLetter(createLetter("supplier1", "letter3"));
226+
await letterRepository.putLetter(
227+
createLetter("supplier1", "letter4", "REJECTED"),
228+
);
229+
await letterRepository.putLetter(createLetter("supplier2", "letter1"));
230+
await letterRepository.putLetter(createLetter("supplier2", "letter2"));
235231

236-
const letters = await letterRepository.getLetterIdsBySupplier('supplier2');
237-
expect(letters).toEqual([]);
232+
const letters = await letterRepository.getLettersBySupplier(
233+
"supplier1",
234+
"PENDING",
235+
10,
236+
);
237+
expect(letters).toEqual([
238+
{
239+
id: "letter1",
240+
specificationId: "specification1",
241+
groupId: 'group1',
242+
status: "PENDING",
243+
},
244+
{
245+
id: "letter2",
246+
specificationId: "specification1",
247+
groupId: 'group1',
248+
status: "PENDING",
249+
},
250+
{
251+
id: "letter3",
252+
specificationId: "specification1",
253+
groupId: 'group1',
254+
status: "PENDING",
255+
},
256+
]);
238257
});
239258

240259
test('should return empty if no letters exist for a supplier', async () => {
241260
await letterRepository.putLetter(createLetter('supplier1', 'letter1'));
242261
await letterRepository.putLetter(createLetter('supplier1', 'letter2'));
243262

244-
const letters = await letterRepository.getLetterIdsBySupplier('supplier2');
263+
const letters = await letterRepository.getLettersBySupplier('supplier2', 'PENDING', 10);
245264
expect(letters).toEqual([]);
246265
});
247266

@@ -252,7 +271,7 @@ describe('LetterRepository', () => {
252271
const mockDdbClient = { send: mockSend } as any;
253272
const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', ttlHours: 1 });
254273

255-
const result = await repo.getLetterIdsBySupplier('supplier1');
256-
expect(result).toEqual([]);
274+
const letters = await repo.getLettersBySupplier('supplier1', 'PENDING', 10);
275+
expect(letters).toEqual([]);
257276
});
258277
});

internal/datastore/src/letter-repository.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
UpdateCommand,
77
UpdateCommandOutput
88
} from '@aws-sdk/lib-dynamodb';
9-
import { Letter, LetterSchema } from './types';
9+
import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from './types';
1010
import { Logger } from 'pino';
11+
import { z } from 'zod';
1112

1213
export type PagingOptions = Partial<{
1314
exclusiveStartKey: Record<string, any>,
@@ -29,10 +30,11 @@ export class LetterRepository {
2930
readonly config: LetterRepositoryConfig) {
3031
}
3132

32-
async putLetter(letter: Omit<Letter, 'ttl' | 'supplierStatus'>): Promise<Letter> {
33+
async putLetter(letter: Omit<Letter, 'ttl' | 'supplierStatus' | 'supplierStatusSk'>): Promise<Letter> {
3334
const letterDb: Letter = {
3435
...letter,
3536
supplierStatus: `${letter.supplierId}#${letter.status}`,
37+
supplierStatusSk: Date.now().toString(),
3638
ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours)
3739
};
3840
try {
@@ -130,15 +132,21 @@ export class LetterRepository {
130132
return LetterSchema.parse(result.Attributes);
131133
}
132134

133-
async getLetterIdsBySupplier(supplierId: string): Promise<string[]> {
135+
async getLettersBySupplier(supplierId: string, status: string, limit: number): Promise<LetterBase[]> {
136+
const supplierStatus = `${supplierId}#${status}`;
134137
const result = await this.ddbClient.send(new QueryCommand({
135138
TableName: this.config.lettersTableName,
136-
KeyConditionExpression: 'supplierId = :supplierId',
139+
IndexName: 'supplierStatus-index',
140+
KeyConditionExpression: 'supplierStatus = :supplierStatus',
141+
Limit: limit,
142+
ExpressionAttributeNames: {
143+
'#status': 'status' // reserved keyword
144+
},
137145
ExpressionAttributeValues: {
138-
':supplierId': supplierId
146+
':supplierStatus': supplierStatus
139147
},
140-
ProjectionExpression: 'id'
148+
ProjectionExpression: 'id, #status, specificationId, groupId, reasonCode, reasonText'
141149
}));
142-
return (result.Items ?? []).map(item => item.id);
150+
return z.array(LetterSchemaBase).parse(result.Items ?? []);
143151
}
144152
}

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, DISPATCHED, FAILED, REJECTED, DELIVERED, CANCELLED"
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

internal/datastore/src/types.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,23 @@ export const LetterStatus = z.enum([
1717
'ENCLOSED', 'CANCELLED', 'DISPATCHED', 'FAILED',
1818
'RETURNED', 'DESTROYED', 'FORWARDED', 'DELIVERED']);
1919

20-
export const LetterSchema = z.object({
20+
export const LetterSchemaBase = z.object({
2121
id: z.string(),
22-
supplierId: idRef(SupplierSchema),
22+
status: LetterStatus,
2323
specificationId: z.string(),
2424
groupId: z.string(),
25+
reasonCode: z.number().optional(),
26+
reasonText: z.string().optional()
27+
});
28+
29+
export const LetterSchema = LetterSchemaBase.extend({
30+
supplierId: z.string(),
2531
url: z.url(),
26-
status: LetterStatus,
2732
createdAt: z.string(),
2833
updatedAt: z.string(),
2934
supplierStatus: z.string().describe('Secondary index PK'),
30-
ttl: z.int()
35+
supplierStatusSk: z.string().describe('Secondary index SK'),
36+
ttl: z.int(),
3137
}).describe('Letter');
3238

3339
/**
@@ -36,6 +42,7 @@ export const LetterSchema = z.object({
3642
* The ttl is used for automatic deletion of old letters.
3743
*/
3844
export type Letter = z.infer<typeof LetterSchema>;
45+
export type LetterBase = z.infer<typeof LetterSchemaBase>;
3946

4047
export const MISchema = z.object({
4148
id: z.string(),

0 commit comments

Comments
 (0)