Skip to content

Commit 48926f6

Browse files
SajidHamza9ThibaRu
andauthored
feat(Model): fetch tableName from (SSM) Parameter Store (#291)
* feat(Model): fetch tableName from (SSM) Parameter Store * fix: fix tests & rewrite SSMParam decorator * fix: add missing dependency * docs(readme): add SSM parameter feature * fix(batchWrite): await tableName --------- Co-authored-by: ThibaultRby <[email protected]>
1 parent 9e1fe7c commit 48926f6

File tree

8 files changed

+877
-227
lines changed

8 files changed

+877
-227
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,29 @@ export class Album extends Model<IAlbum> {
6161
}
6262
```
6363

64+
You have the option to either directly provide the table name, as shown in the previous example, or retrieve it from the AWS Systems Manager (SSM) Parameter Store by specifying the ARN of the SSM parameter. The value will be automatically replaced at runtime.
65+
66+
Here is an example of using table name as SSM Parameter ARN
67+
68+
```typescript
69+
interface IAlbum {
70+
artist: string;
71+
album: string;
72+
year?: number;
73+
genres?: string[];
74+
}
75+
76+
export class Album extends Model<IAlbum> {
77+
protected tableName = 'arn:aws:ssm:<aws-region>:<aws-account-id>:parameter/ParameterName';
78+
protected hashkey = 'artist';
79+
protected rangekey = 'album';
80+
81+
constructor(item?: IAlbum) {
82+
super(item);
83+
}
84+
}
85+
```
86+
6487
Here is another example for a table with a simple hashkey:
6588

6689
```typescript

package-lock.json

Lines changed: 710 additions & 213 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
},
2020
"dependencies": {
2121
"@aws-sdk/client-dynamodb": "^3.445.0",
22+
"@aws-sdk/client-ssm": "^3.496.0",
2223
"@aws-sdk/lib-dynamodb": "^3.445.0",
2324
"joi": "^17.11.0",
25+
"reflect-metadata": "^0.2.1",
2426
"uuid": "^9.0.1"
2527
},
2628
"devDependencies": {

src/base-model.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import Scan from './scan';
2525
import { IUpdateActions, buildUpdateActions, put } from './update-operators';
2626
import ValidationError from './validation-error';
2727
import { PutCommandInput } from '@aws-sdk/lib-dynamodb/dist-types/commands/PutCommand';
28+
import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
2829

2930
export type KeyValue = string | number | Buffer | boolean | null;
3031
type SimpleKey = KeyValue;
@@ -36,8 +37,45 @@ const isCompositeKey = (hashKeys_compositeKeys: Keys): hashKeys_compositeKeys is
3637

3738
const isSimpleKey = (hashKeys_compositeKeys: Keys): hashKeys_compositeKeys is SimpleKey[] => hashKeys_compositeKeys.length > 0 && Model.isKey(hashKeys_compositeKeys[0]);
3839

40+
export const fetchTableName = async (tableName: string) => {
41+
if (tableName.startsWith('arn:aws:ssm')) {
42+
const ssmArnRegex = /^arn:aws:ssm:([a-z0-9-]+):\d{12}:parameter\/([a-zA-Z0-9_.\-/]+)$/;
43+
const isMatchingArn = tableName.match(ssmArnRegex)
44+
if (!isMatchingArn) {
45+
throw new Error("Invalid syntax for table name as SSM Parameter");
46+
}
47+
const [_, region, parameterName] = isMatchingArn;
48+
const ssmClient = new SSMClient({ region });
49+
const getValue = new GetParameterCommand({
50+
Name: parameterName,
51+
});
52+
try {
53+
const { Parameter } = await ssmClient.send(getValue);
54+
return Parameter?.Value;
55+
}
56+
catch (e) {
57+
throw new Error("Invalid SSM Parameter");
58+
}
59+
}
60+
return tableName;
61+
}
62+
63+
const SSMParam = (target: any, key: string) => {
64+
const symbol = Symbol();
65+
Reflect.defineProperty(target, key, {
66+
get: function () {
67+
return (async () => await this[symbol])();
68+
},
69+
set: function (newVal: string) {
70+
this[symbol] = fetchTableName(newVal);
71+
}
72+
})
73+
}
74+
3975
export default abstract class Model<T> {
40-
protected tableName: string | undefined;
76+
77+
@SSMParam
78+
protected tableName: string | Promise<string | undefined> | undefined;
4179

4280
protected item: T | undefined;
4381

@@ -235,7 +273,7 @@ export default abstract class Model<T> {
235273
}
236274
// Prepare putItem operation
237275
const params: PutCommandInput = {
238-
TableName: this.tableName,
276+
TableName: await this.tableName,
239277
Item: toSave,
240278
};
241279
// Overload putItem parameters with options given in arguments (if any)
@@ -275,7 +313,7 @@ export default abstract class Model<T> {
275313
// Prepare getItem operation
276314
this.testKeys(pk, sk);
277315
const params: GetCommandInput = {
278-
TableName: this.tableName,
316+
TableName: await this.tableName,
279317
Key: this.buildKeys(pk, sk),
280318
};
281319
// Overload getItem parameters with options given in arguments (if any)
@@ -376,7 +414,7 @@ export default abstract class Model<T> {
376414
throw new Error('Item to delete does not exists');
377415
}
378416
const params: DeleteCommandInput = {
379-
TableName: this.tableName,
417+
TableName: await this.tableName,
380418
Key: this.buildKeys(pk, sk),
381419
};
382420
if (options) {
@@ -399,12 +437,12 @@ export default abstract class Model<T> {
399437
throw new Error('Primary key is not defined on your model');
400438
}
401439
const params: ScanCommandInput = {
402-
TableName: this.tableName,
440+
TableName: '',
403441
};
404442
if (options) {
405443
Object.assign(params, options);
406444
}
407-
return new Scan(this.documentClient, params, this.pk, this.sk);
445+
return new Scan(this.documentClient, params, this.tableName, this.pk, this.sk);
408446
}
409447

410448
/**
@@ -445,15 +483,15 @@ export default abstract class Model<T> {
445483
: (index_options as Partial<QueryCommandInput>);
446484
// Building query
447485
const params: QueryCommandInput = {
448-
TableName: this.tableName,
486+
TableName: '',
449487
};
450488
if (indexName) {
451489
params.IndexName = indexName;
452490
}
453491
if (queryOptions) {
454492
Object.assign(params, queryOptions);
455493
}
456-
return new Query(this.documentClient, params, this.pk, this.sk);
494+
return new Query(this.documentClient, params, this.tableName, this.pk, this.sk);
457495
}
458496

459497
/**
@@ -473,15 +511,15 @@ export default abstract class Model<T> {
473511
if (isCompositeKey(keys)) {
474512
params = {
475513
RequestItems: {
476-
[this.tableName]: {
514+
[await this.tableName as string]: {
477515
Keys: keys.map((k) => this.buildKeys(k.pk, k.sk)),
478516
},
479517
},
480518
};
481519
} else {
482520
params = {
483521
RequestItems: {
484-
[this.tableName]: {
522+
[await this.tableName as string]: {
485523
Keys: keys.map((pk) => ({ [String(this.pk)]: pk })),
486524
},
487525
},
@@ -491,7 +529,7 @@ export default abstract class Model<T> {
491529
Object.assign(params, options);
492530
}
493531
const result = await this.documentClient.send(new BatchGetCommand(params));
494-
return result.Responses ? (result.Responses[this.tableName] as T[]) : [];
532+
return result.Responses ? (result.Responses[await this.tableName as string] as T[]) : [];
495533
}
496534

497535
/**
@@ -565,7 +603,7 @@ export default abstract class Model<T> {
565603
updateActions['updatedAt'] = put(new Date().toISOString());
566604
}
567605
const params: UpdateCommandInput = {
568-
TableName: this.tableName,
606+
TableName: await this.tableName,
569607
Key: this.buildKeys(pk, sk),
570608
AttributeUpdates: buildUpdateActions(updateActions),
571609
};
@@ -671,10 +709,10 @@ export default abstract class Model<T> {
671709
//Make one BatchWrite request for every batch of 25 operations
672710
let params: BatchWriteCommandInput;
673711
const output: BatchWriteCommandOutput[] = await Promise.all(
674-
batches.map(batch => {
712+
batches.map(async (batch) => {
675713
params = {
676714
RequestItems: {
677-
[this.tableName as string]: batch
715+
[await this.tableName as string]: batch
678716
}
679717
}
680718
if (options) {

src/query.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default class Query<T> extends Operation<T> {
2020
constructor(
2121
documentClient: DynamoDBDocumentClient,
2222
params: QueryCommandInput | ScanCommandInput,
23+
private readonly tableName: Promise<string | undefined> | string | undefined,
2324
pk: string,
2425
sk?: string,
2526
) {
@@ -92,6 +93,7 @@ export default class Query<T> extends Operation<T> {
9293
}
9394

9495
public async doExec(): Promise<IPaginatedResult<T>> {
96+
this.params.TableName = await this.tableName;
9597
const result = await this.documentClient.send(new QueryCommand(this.params));
9698
return this.buildResponse(result);
9799
}

src/scan.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class Scan<T> extends Operation<T> {
1515
constructor(
1616
documentClient: DynamoDBDocumentClient,
1717
params: QueryCommandInput | ScanCommandInput,
18+
private readonly tableName: Promise<string | undefined> | string | undefined,
1819
pk: string,
1920
sk?: string,
2021
) {
@@ -47,6 +48,7 @@ export default class Scan<T> extends Operation<T> {
4748
* @returns Fetched items, and pagination metadata
4849
*/
4950
public async doExec(): Promise<IPaginatedResult<T>> {
51+
this.params.TableName = await this.tableName;
5052
const result = await this.documentClient.send(new ScanCommand(this.params));
5153
return this.buildResponse(result);
5254
}

test/models/ssm-param.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { HashKeyEntity } from "./hashkey";
2+
import documentClient from './common';
3+
;
4+
import Model from "../../src/base-model";
5+
export default class SSMParamModel extends Model<HashKeyEntity> {
6+
protected tableName = 'arn:aws:ssm:us-east-1:617599655210:parameter/tableName';
7+
8+
protected pk = 'hashkey';
9+
10+
protected documentClient = documentClient;
11+
}

test/ssm-parameter.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
2+
import { clearTables } from './hooks/create-tables';
3+
import * as modelApi from '../src/base-model';
4+
import SSMParamModel from './models/ssm-param';
5+
6+
jest.mock('@aws-sdk/client-ssm');
7+
8+
describe('fetchTableName', () => {
9+
afterEach(async () => {
10+
jest.clearAllMocks();
11+
});
12+
test('should throw an error', async () => {
13+
const mockSend = jest.fn();
14+
SSMClient.prototype.send = mockSend;
15+
mockSend.mockRejectedValueOnce(new Error());
16+
try {
17+
await modelApi.fetchTableName('arn:aws:ssm:us-east-1:617599655210:parameter/tableName');
18+
} catch (e) {
19+
expect((e as Error).message.includes('Invalid SSM Parameter')).toBe(true);
20+
}
21+
});
22+
test('should fetch tableName from SSM parameter store', async () => {
23+
const mockSend = jest.fn();
24+
mockSend.mockResolvedValueOnce({ Parameter: { Value: 'table_test_hashkey' } });
25+
SSMClient.prototype.send = mockSend;
26+
const tableName = await modelApi.fetchTableName('arn:aws:ssm:us-east-1:617599655210:parameter/tableName');
27+
expect(GetParameterCommand).toHaveBeenCalledWith({ Name: 'tableName' });
28+
expect(mockSend).toHaveBeenCalledWith(expect.any(GetParameterCommand));
29+
expect(tableName).toEqual('table_test_hashkey');
30+
});
31+
test('should return the provided tableName', async () => {
32+
const mockSend = jest.fn();
33+
SSMClient.prototype.send = mockSend;
34+
const mockResponse = { Parameter: { Value: 'table_test_hashkey' } };
35+
mockSend.mockResolvedValueOnce(mockResponse);
36+
const tableName = await modelApi.fetchTableName('tableName');
37+
expect(mockSend).not.toHaveBeenCalled();
38+
expect(GetParameterCommand).not.toHaveBeenCalled();
39+
expect(tableName).toEqual('tableName');
40+
});
41+
});
42+
43+
describe('SSM parameter ARN', () => {
44+
const item = {
45+
hashkey: 'bar',
46+
string: 'whatever',
47+
stringmap: { foo: 'bar' },
48+
stringset: ['bar, bar'],
49+
number: 43,
50+
bool: true,
51+
list: ['foo', 42],
52+
};
53+
beforeEach(async () => {
54+
const mockSend = jest.fn();
55+
mockSend.mockResolvedValueOnce({ Parameter: { Value: 'table_test_hashkey' } });
56+
SSMClient.prototype.send = mockSend;
57+
await clearTables();
58+
});
59+
afterEach(async () => {
60+
jest.clearAllMocks();
61+
});
62+
test('Should fetch tableName and save the item', async () => {
63+
const model = new SSMParamModel();
64+
await model.save(item);
65+
const saved = await model.get('bar');
66+
expect(saved).toEqual(item);
67+
});
68+
test('Should fetch tableName once and cache its value for subsequent requests', async () => {
69+
jest.spyOn(modelApi, 'fetchTableName');
70+
const model = new SSMParamModel();
71+
await model.save(item);
72+
await model.save(item);
73+
expect(modelApi.fetchTableName).toHaveBeenCalledTimes(1);
74+
});
75+
});

0 commit comments

Comments
 (0)