Skip to content

Commit a2de862

Browse files
committed
feat: added baseprovider class
1 parent 36caa4e commit a2de862

12 files changed

+468
-1
lines changed

Diff for: package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"packages/commons",
99
"packages/logger",
1010
"packages/metrics",
11-
"packages/tracer"
11+
"packages/tracer",
12+
"packages/parameters"
1213
],
1314
"scripts": {
1415
"init-environment": "husky install",

Diff for: packages/parameters/jest.config.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
module.exports = {
2+
displayName: {
3+
name: 'AWS Lambda Powertools utility: PARAMETERS',
4+
color: 'magenta',
5+
},
6+
'runner': 'groups',
7+
'preset': 'ts-jest',
8+
'transform': {
9+
'^.+\\.ts?$': 'ts-jest',
10+
},
11+
moduleFileExtensions: [ 'js', 'ts' ],
12+
'collectCoverageFrom': [
13+
'**/src/**/*.ts',
14+
'!**/node_modules/**',
15+
],
16+
'testMatch': ['**/?(*.)+(spec|test).ts'],
17+
'roots': [
18+
'<rootDir>/src',
19+
'<rootDir>/tests',
20+
],
21+
'testPathIgnorePatterns': [
22+
'/node_modules/',
23+
],
24+
'testEnvironment': 'node',
25+
'coveragePathIgnorePatterns': [
26+
'/node_modules/',
27+
'/types/',
28+
],
29+
'coverageThreshold': {
30+
'global': {
31+
'statements': 100,
32+
'branches': 100,
33+
'functions': 100,
34+
'lines': 100,
35+
},
36+
},
37+
'coverageReporters': [
38+
'json-summary',
39+
'text',
40+
'lcov'
41+
],
42+
'setupFiles': [
43+
'<rootDir>/tests/helpers/populateEnvironmentVariables.ts'
44+
]
45+
};

Diff for: packages/parameters/package.json

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@aws-lambda-powertools/parameters",
3+
"version": "1.4.1",
4+
"description": "The parameters package for the AWS Lambda Powertools for TypeScript library",
5+
"author": {
6+
"name": "Amazon Web Services",
7+
"url": "https://aws.amazon.com"
8+
},
9+
"publishConfig": {
10+
"access": "public"
11+
},
12+
"scripts": {
13+
"commit": "commit",
14+
"test": "npm run test:unit",
15+
"test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose",
16+
"test:e2e:nodejs12x": "echo \"Not implemented\"",
17+
"test:e2e:nodejs14x": "echo \"Not implemented\"",
18+
"test:e2e:nodejs16x": "echo \"Not implemented\"",
19+
"test:e2e": "echo \"Not implemented\"",
20+
"watch": "jest --watch",
21+
"build": "tsc",
22+
"lint": "eslint --ext .ts --no-error-on-unmatched-pattern src tests",
23+
"lint-fix": "eslint --fix --ext .ts --no-error-on-unmatched-pattern src tests",
24+
"package": "mkdir -p dist/ && npm pack && mv *.tgz dist/",
25+
"package-bundle": "../../package-bundler.sh parameters-bundle ./dist",
26+
"prepare": "npm run build",
27+
"postversion": "git push --tags"
28+
},
29+
"homepage": "https://github.com/awslabs/aws-lambda-powertools-typescript/tree/main/packages/parameters#readme",
30+
"license": "MIT-0",
31+
"main": "./lib/index.js",
32+
"types": "./lib/index.d.ts",
33+
"devDependencies": {},
34+
"files": [
35+
"lib"
36+
],
37+
"repository": {
38+
"type": "git",
39+
"url": "git+https://github.com/awslabs/aws-lambda-powertools-typescript.git"
40+
},
41+
"bugs": {
42+
"url": "https://github.com/awslabs/aws-lambda-powertools-typescript/issues"
43+
},
44+
"dependencies": {},
45+
"keywords": [
46+
"aws",
47+
"lambda",
48+
"powertools",
49+
"ssm",
50+
"secrets",
51+
"serverless",
52+
"nodejs"
53+
]
54+
}

Diff for: packages/parameters/src/BaseProvider.ts

+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { fromBase64 } from '@aws-sdk/util-base64-node';
2+
import { GetParameterError, TransformParameterError } from 'Exceptions';
3+
import type { BaseProviderInterface, ExpirableValueInterface, GetMultipleOptionsInterface, GetOptionsInterface, Key, TransformOptions } from './types';
4+
5+
const DEFAULT_MAX_AGE_SECS = 5;
6+
// These providers will be dynamically initialized on first use of the helper functions
7+
const DEFAULT_PROVIDERS = new Map();
8+
const TRANSFORM_METHOD_JSON = 'json';
9+
const TRANSFORM_METHOD_BINARY = 'binary';
10+
11+
class GetOptions implements GetOptionsInterface {
12+
public forceFetch: boolean = false;
13+
public maxAge: number = DEFAULT_MAX_AGE_SECS;
14+
public sdkOptions?: unknown;
15+
public transform?: TransformOptions;
16+
17+
public constructor(options: GetOptionsInterface = {}) {
18+
Object.assign(this, options);
19+
}
20+
}
21+
22+
class GetMultipleOptions implements GetMultipleOptionsInterface {
23+
public forceFetch: boolean = false;
24+
public maxAge: number = DEFAULT_MAX_AGE_SECS;
25+
public sdkOptions?: unknown;
26+
public throwOnTransformError?: boolean = false;
27+
public transform?: TransformOptions;
28+
29+
public constructor(options: GetMultipleOptionsInterface) {
30+
Object.assign(this, options);
31+
}
32+
}
33+
34+
class ExpirableValue implements ExpirableValueInterface {
35+
public ttl: number;
36+
public value: string | Record<string, unknown>;
37+
38+
public constructor(value: string | Record<string, unknown>, maxAge: number) {
39+
this.value = value;
40+
const timeNow = new Date();
41+
this.ttl = timeNow.setSeconds(timeNow.getSeconds() + maxAge);
42+
}
43+
44+
public isExpired(): boolean {
45+
return this.ttl < Date.now();
46+
}
47+
}
48+
49+
abstract class BaseProvider implements BaseProviderInterface {
50+
public store: Map<Key, ExpirableValue> = new Map;
51+
52+
private constructor () {
53+
this.store = new Map();
54+
}
55+
56+
public addToCache(key: Key, value: string | Record<string, unknown>, maxAge: number): void {
57+
if (maxAge <= 0) return;
58+
59+
this.store.set(key, new ExpirableValue(value, maxAge));
60+
}
61+
62+
public clearCache(): void {
63+
this.store.clear();
64+
}
65+
66+
/**
67+
* Retrieve a parameter value or return the cached value
68+
*
69+
* If there are multiple calls to the same parameter but in a different transform, they will be stored multiple times.
70+
* This allows us to optimize by transforming the data only once per retrieval, thus there is no need to transform cached values multiple times.
71+
*
72+
* However, this means that we need to make multiple calls to the underlying parameter store if we need to return it in different transforms.
73+
*
74+
* Since the number of supported transform is small and the probability that a given parameter will always be used in a specific transform,
75+
* this should be an acceptable tradeoff.
76+
*
77+
* @param {string} name - Parameter name
78+
* @param {GetOptionsInterface} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch
79+
*/
80+
public async get(name: string, options?: GetOptionsInterface): Promise<undefined | string | Record<string, unknown>> {
81+
const configs = new GetOptions(options);
82+
const key = { [name]: configs.transform };
83+
84+
if (!configs.forceFetch && this.hasNotExpiredInCache(key)) {
85+
return this.store.get(key)?.value;
86+
}
87+
88+
let value;
89+
try {
90+
value = await this._get(name, options?.sdkOptions);
91+
} catch (error) {
92+
throw new GetParameterError((error as Error).message);
93+
}
94+
95+
if (value && configs.transform) {
96+
value = transformValue(value, configs.transform);
97+
}
98+
99+
if (value) {
100+
this.addToCache(key, value, configs.maxAge);
101+
}
102+
103+
// TODO: revisit return type once providers are implemented, it might be missing binary when not transformed
104+
return value;
105+
}
106+
107+
public async getMultiple(path: string, options?: GetMultipleOptionsInterface): Promise<undefined | Record<string, unknown>> {
108+
const configs = new GetMultipleOptions(options || {});
109+
const key = { [path]: configs.transform };
110+
111+
if (!configs.forceFetch && this.hasNotExpiredInCache(key)) {
112+
return this.store.get(key)?.value as Record<string, unknown>; // In this case we know that if it exists, this key corresponds to a Record
113+
}
114+
115+
let values: Record<string, unknown> = {};
116+
try {
117+
values = await this._getMultiple(path, options?.sdkOptions);
118+
} catch (error) {
119+
throw new GetParameterError((error as Error).message);
120+
}
121+
122+
if (configs.transform) {
123+
values = transformValues(values, configs.transform);
124+
}
125+
126+
if (Array.from(Object.keys(values)).length !== 0) {
127+
this.addToCache(key, values, configs.maxAge);
128+
}
129+
130+
// TODO: revisit return type once providers are implemented, it might be missing something
131+
return values;
132+
}
133+
134+
/**
135+
* Retrieve parameter value from the underlying parameter store
136+
*
137+
* @param {string} name - Parameter name
138+
* @param {unknown} sdkOptions - Options to pass to the underlying AWS SDK
139+
*/
140+
protected abstract _get(name: string, sdkOptions?: unknown): Promise<string | undefined>;
141+
142+
protected abstract _getMultiple(path: string, sdkOptions?: unknown): Promise<Record<string, string|undefined>>;
143+
144+
/**
145+
* Check whether a key has not expired in the cache
146+
*
147+
* It returns false if the key is expired or not present in the cache.
148+
*
149+
* @param {Key} key - Key to retrieve
150+
*/
151+
private hasNotExpiredInCache(key: Key): boolean {
152+
const value = this.store.get(key);
153+
if (value) value.isExpired();
154+
155+
return false;
156+
}
157+
158+
}
159+
160+
const transformValue = (value: string, transform: TransformOptions, throwOnTransformError: boolean = true, key: string = ''): string | Record<string, unknown> | undefined => {
161+
try {
162+
const normalizedTransform = transform.toLowerCase();
163+
if (
164+
normalizedTransform === TRANSFORM_METHOD_JSON ||
165+
(normalizedTransform === 'auto' && key.toLowerCase().endsWith(`.${TRANSFORM_METHOD_JSON}`))
166+
) {
167+
return JSON.parse(value) as Record<string, unknown>;
168+
} else if (
169+
normalizedTransform === TRANSFORM_METHOD_BINARY ||
170+
(normalizedTransform === 'auto' && key.toLowerCase().endsWith(`.${TRANSFORM_METHOD_BINARY}`))
171+
) {
172+
return new TextDecoder('utf-8').decode(fromBase64(value));
173+
} else {
174+
throw Error(`Invalid transform type ${normalizedTransform}.`);
175+
}
176+
} catch (error) {
177+
if (throwOnTransformError)
178+
throw new TransformParameterError(transform, (error as Error).message);
179+
180+
return;
181+
}
182+
};
183+
184+
const transformValues = (value: string | Uint8Array | Record<string, unknown>, transform: TransformOptions, throwOnTransformError: boolean = true): Record<string, unknown> => {
185+
const transformedValues: Record<string, unknown> = {};
186+
for (const entry in Object.entries(value)) {
187+
const [ entryKey, entryValue ] = entry;
188+
try {
189+
transformedValues[entryKey] = transformValue(entryValue, transform, true, entryKey);
190+
} catch (error) {
191+
if (throwOnTransformError)
192+
throw new TransformParameterError(transform, (error as Error).message);
193+
transformedValues[entryKey] = undefined;
194+
}
195+
}
196+
197+
return transformedValues;
198+
};
199+
200+
const clearCaches = (): void => DEFAULT_PROVIDERS.clear();
201+
202+
export {
203+
clearCaches,
204+
BaseProvider,
205+
transformValue,
206+
DEFAULT_PROVIDERS
207+
};

Diff for: packages/parameters/src/Exceptions.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class GetParameterError extends Error {}
2+
3+
class TransformParameterError extends Error {
4+
public constructor(transform: string, message: string) {
5+
super(message);
6+
7+
this.message = `Unable to transform value using '${transform}' transform: ${message}`;
8+
}
9+
}
10+
11+
export {
12+
GetParameterError,
13+
TransformParameterError,
14+
};

Diff for: packages/parameters/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './BaseProvider';

Diff for: packages/parameters/src/types/BaseProvider.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type TransformOptions = 'auto' | 'binary' | 'json';
2+
3+
interface GetOptionsInterface {
4+
maxAge?: number
5+
forceFetch?: boolean
6+
sdkOptions?: unknown
7+
transform?: TransformOptions
8+
}
9+
10+
interface GetMultipleOptionsInterface {
11+
maxAge?: number
12+
forceFetch?: boolean
13+
sdkOptions?: unknown
14+
transform?: string
15+
throwOnTransformError?: boolean
16+
}
17+
18+
interface Key {
19+
[key: string]: TransformOptions | undefined
20+
}
21+
22+
interface ExpirableValueInterface {
23+
value: string | Record<string, unknown>
24+
ttl: number
25+
}
26+
27+
interface BaseProviderInterface {
28+
get(name: string, options?: GetOptionsInterface): Promise<undefined | string | Record<string, unknown>>
29+
getMultiple(path: string, options?: GetMultipleOptionsInterface): Promise<void | Record<string, unknown>>
30+
}
31+
32+
type TransformValueFn = {
33+
(value: string | Uint8Array, transform: TransformOptions, throwOnTransformError?: boolean, key?: string): string | Record<string, unknown> | undefined
34+
(value: Record<string, unknown>, transform: TransformOptions, throwOnTransformError?: boolean, key?: string): Record<string, unknown>
35+
};
36+
37+
export {
38+
Key,
39+
GetOptionsInterface,
40+
GetMultipleOptionsInterface,
41+
BaseProviderInterface,
42+
ExpirableValueInterface,
43+
TransformOptions,
44+
TransformValueFn
45+
};

Diff for: packages/parameters/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './BaseProvider';

0 commit comments

Comments
 (0)