diff --git a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml index 991260cb76..ce7c0e1996 100644 --- a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml +++ b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml @@ -42,11 +42,11 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit == 'true' run: | npm run build -w packages/commons - npm run build -w packages/logger & npm run build -w packages/tracer & npm run build -w packages/metrics + npm run build -w packages/logger & npm run build -w packages/tracer & npm run build -w packages/metrics -w packages/parameters - name: Run linting - run: npm run lint -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics + run: npm run lint -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters - name: Run unit tests - run: npm t -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics + run: npm t -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters check-examples: runs-on: ubuntu-latest env: diff --git a/package.json b/package.json index c077b3c592..778f80ffa1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "packages/commons", "packages/logger", "packages/metrics", - "packages/tracer" + "packages/tracer", + "packages/parameters" ], "scripts": { "init-environment": "husky install", diff --git a/packages/parameters/jest.config.js b/packages/parameters/jest.config.js new file mode 100644 index 0000000000..6c267d005e --- /dev/null +++ b/packages/parameters/jest.config.js @@ -0,0 +1,45 @@ +module.exports = { + displayName: { + name: 'AWS Lambda Powertools utility: PARAMETERS', + color: 'magenta', + }, + 'runner': 'groups', + 'preset': 'ts-jest', + 'transform': { + '^.+\\.ts?$': 'ts-jest', + }, + moduleFileExtensions: [ 'js', 'ts' ], + 'collectCoverageFrom': [ + '**/src/**/*.ts', + '!**/node_modules/**', + ], + 'testMatch': ['**/?(*.)+(spec|test).ts'], + 'roots': [ + '/src', + '/tests', + ], + 'testPathIgnorePatterns': [ + '/node_modules/', + ], + 'testEnvironment': 'node', + 'coveragePathIgnorePatterns': [ + '/node_modules/', + '/types/', + ], + 'coverageThreshold': { + 'global': { + 'statements': 100, + 'branches': 100, + 'functions': 100, + 'lines': 100, + }, + }, + 'coverageReporters': [ + 'json-summary', + 'text', + 'lcov' + ], + 'setupFiles': [ + '/tests/helpers/populateEnvironmentVariables.ts' + ] +}; \ No newline at end of file diff --git a/packages/parameters/package.json b/packages/parameters/package.json new file mode 100644 index 0000000000..fffbdbc373 --- /dev/null +++ b/packages/parameters/package.json @@ -0,0 +1,54 @@ +{ + "name": "@aws-lambda-powertools/parameters", + "version": "1.4.1", + "description": "The parameters package for the AWS Lambda Powertools for TypeScript library", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "commit": "commit", + "test": "npm run test:unit", + "test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose", + "test:e2e:nodejs12x": "echo \"Not implemented\"", + "test:e2e:nodejs14x": "echo \"Not implemented\"", + "test:e2e:nodejs16x": "echo \"Not implemented\"", + "test:e2e": "echo \"Not implemented\"", + "watch": "jest --watch", + "build": "tsc", + "lint": "eslint --ext .ts --no-error-on-unmatched-pattern src tests", + "lint-fix": "eslint --fix --ext .ts --no-error-on-unmatched-pattern src tests", + "package": "mkdir -p dist/ && npm pack && mv *.tgz dist/", + "package-bundle": "../../package-bundler.sh parameters-bundle ./dist", + "prepare": "npm run build", + "postversion": "git push --tags" + }, + "homepage": "https://github.com/awslabs/aws-lambda-powertools-typescript/tree/main/packages/parameters#readme", + "license": "MIT-0", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "devDependencies": {}, + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/awslabs/aws-lambda-powertools-typescript.git" + }, + "bugs": { + "url": "https://github.com/awslabs/aws-lambda-powertools-typescript/issues" + }, + "dependencies": {}, + "keywords": [ + "aws", + "lambda", + "powertools", + "ssm", + "secrets", + "serverless", + "nodejs" + ] +} \ No newline at end of file diff --git a/packages/parameters/src/BaseProvider.ts b/packages/parameters/src/BaseProvider.ts new file mode 100644 index 0000000000..9a081cc7f5 --- /dev/null +++ b/packages/parameters/src/BaseProvider.ts @@ -0,0 +1,170 @@ +import { fromBase64 } from '@aws-sdk/util-base64-node'; +import { GetOptions } from './GetOptions'; +import { GetMultipleOptions } from './GetMultipleOptions'; +import { ExpirableValue } from './ExpirableValue'; +import { TRANSFORM_METHOD_BINARY, TRANSFORM_METHOD_JSON } from './constants'; +import { GetParameterError, TransformParameterError } from './Exceptions'; +import type { BaseProviderInterface, GetMultipleOptionsInterface, GetOptionsInterface, TransformOptions } from './types'; + +abstract class BaseProvider implements BaseProviderInterface { + protected store: Map; + + public constructor () { + this.store = new Map(); + } + + public addToCache(key: string, value: string | Record, maxAge: number): void { + if (maxAge <= 0) return; + + this.store.set(key, new ExpirableValue(value, maxAge)); + } + + public clearCache(): void { + this.store.clear(); + } + + /** + * Retrieve a parameter value or return the cached value + * + * If there are multiple calls to the same parameter but in a different transform, they will be stored multiple times. + * This allows us to optimize by transforming the data only once per retrieval, thus there is no need to transform cached values multiple times. + * + * However, this means that we need to make multiple calls to the underlying parameter store if we need to return it in different transforms. + * + * Since the number of supported transform is small and the probability that a given parameter will always be used in a specific transform, + * this should be an acceptable tradeoff. + * + * @param {string} name - Parameter name + * @param {GetOptionsInterface} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch + */ + public async get(name: string, options?: GetOptionsInterface): Promise> { + const configs = new GetOptions(options); + const key = [ name, configs.transform ].toString(); + + if (!configs.forceFetch && !this.hasKeyExpiredInCache(key)) { + // If the code enters in this block, then the key must exist & not have been expired + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.store.get(key)!.value; + } + + let value; + try { + value = await this._get(name, options?.sdkOptions); + } catch (error) { + throw new GetParameterError((error as Error).message); + } + + if (value && configs.transform) { + value = transformValue(value, configs.transform, true); + } + + if (value) { + this.addToCache(key, value, configs.maxAge); + } + + // TODO: revisit return type once providers are implemented, it might be missing binary when not transformed + return value; + } + + public async getMultiple(path: string, options?: GetMultipleOptionsInterface): Promise> { + const configs = new GetMultipleOptions(options || {}); + const key = [ path, configs.transform ].toString(); + + if (!configs.forceFetch && !this.hasKeyExpiredInCache(key)) { + // If the code enters in this block, then the key must exist & not have been expired + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.store.get(key)!.value as Record; + } + + let values: Record = {}; + try { + values = await this._getMultiple(path, options?.sdkOptions); + } catch (error) { + throw new GetParameterError((error as Error).message); + } + + if (Object.keys(values) && configs.transform) { + values = transformValues(values, configs.transform, configs.throwOnTransformError); + } + + if (Array.from(Object.keys(values)).length !== 0) { + this.addToCache(key, values, configs.maxAge); + } + + // TODO: revisit return type once providers are implemented, it might be missing something + return values; + } + + /** + * Retrieve parameter value from the underlying parameter store + * + * @param {string} name - Parameter name + * @param {unknown} sdkOptions - Options to pass to the underlying AWS SDK + */ + protected abstract _get(name: string, sdkOptions?: unknown): Promise; + + protected abstract _getMultiple(path: string, sdkOptions?: unknown): Promise>; + + /** + * Check whether a key has expired in the cache or not + * + * It returns true if the key is expired or not present in the cache. + * + * @param {string} key - Stringified representation of the key to retrieve + */ + private hasKeyExpiredInCache(key: string): boolean { + const value = this.store.get(key); + if (value) return value.isExpired(); + + return true; + } + +} + +// TODO: revisit `value` type once we are clearer on the types returned by the various SDKs +const transformValue = (value: unknown, transform: TransformOptions, throwOnTransformError: boolean, key: string = ''): string | Record | undefined => { + try { + const normalizedTransform = transform.toLowerCase(); + if ( + (normalizedTransform === TRANSFORM_METHOD_JSON || + (normalizedTransform === 'auto' && key.toLowerCase().endsWith(`.${TRANSFORM_METHOD_JSON}`))) && + typeof value === 'string' + ) { + return JSON.parse(value) as Record; + } else if ( + (normalizedTransform === TRANSFORM_METHOD_BINARY || + (normalizedTransform === 'auto' && key.toLowerCase().endsWith(`.${TRANSFORM_METHOD_BINARY}`))) && + typeof value === 'string' + ) { + return new TextDecoder('utf-8').decode(fromBase64(value)); + } else { + // TODO: revisit this type once we are clearer on types returned by SDKs + return value as string; + } + } catch (error) { + if (throwOnTransformError) + throw new TransformParameterError(transform, (error as Error).message); + + return; + } +}; + +const transformValues = (value: Record, transform: TransformOptions, throwOnTransformError: boolean): Record => { + const transformedValues: Record = {}; + for (const [ entryKey, entryValue ] of Object.entries(value)) { + try { + transformedValues[entryKey] = transformValue(entryValue, transform, throwOnTransformError, entryKey); + } catch (error) { + if (throwOnTransformError) + throw new TransformParameterError(transform, (error as Error).message); + } + } + + return transformedValues; +}; + +export { + BaseProvider, + ExpirableValue, + transformValue, +}; \ No newline at end of file diff --git a/packages/parameters/src/Exceptions.ts b/packages/parameters/src/Exceptions.ts new file mode 100644 index 0000000000..845577e453 --- /dev/null +++ b/packages/parameters/src/Exceptions.ts @@ -0,0 +1,14 @@ +class GetParameterError extends Error {} + +class TransformParameterError extends Error { + public constructor(transform: string, message: string) { + super(message); + + this.message = `Unable to transform value using '${transform}' transform: ${message}`; + } +} + +export { + GetParameterError, + TransformParameterError, +}; \ No newline at end of file diff --git a/packages/parameters/src/ExpirableValue.ts b/packages/parameters/src/ExpirableValue.ts new file mode 100644 index 0000000000..d4b3b2bda4 --- /dev/null +++ b/packages/parameters/src/ExpirableValue.ts @@ -0,0 +1,20 @@ +import type { ExpirableValueInterface } from './types'; + +class ExpirableValue implements ExpirableValueInterface { + public ttl: number; + public value: string | Record; + + public constructor(value: string | Record, maxAge: number) { + this.value = value; + const timeNow = new Date(); + this.ttl = timeNow.setSeconds(timeNow.getSeconds() + maxAge); + } + + public isExpired(): boolean { + return this.ttl < Date.now(); + } +} + +export { + ExpirableValue +}; \ No newline at end of file diff --git a/packages/parameters/src/GetMultipleOptions.ts b/packages/parameters/src/GetMultipleOptions.ts new file mode 100644 index 0000000000..5f03a0ee3e --- /dev/null +++ b/packages/parameters/src/GetMultipleOptions.ts @@ -0,0 +1,18 @@ +import { DEFAULT_MAX_AGE_SECS } from './constants'; +import type { GetMultipleOptionsInterface, TransformOptions } from './types'; + +class GetMultipleOptions implements GetMultipleOptionsInterface { + public forceFetch: boolean = false; + public maxAge: number = DEFAULT_MAX_AGE_SECS; + public sdkOptions?: unknown; + public throwOnTransformError: boolean = false; + public transform?: TransformOptions; + + public constructor(options: GetMultipleOptionsInterface) { + Object.assign(this, options); + } +} + +export { + GetMultipleOptions +}; \ No newline at end of file diff --git a/packages/parameters/src/GetOptions.ts b/packages/parameters/src/GetOptions.ts new file mode 100644 index 0000000000..807962a5aa --- /dev/null +++ b/packages/parameters/src/GetOptions.ts @@ -0,0 +1,17 @@ +import { DEFAULT_MAX_AGE_SECS } from './constants'; +import type { GetOptionsInterface, TransformOptions } from './types'; + +class GetOptions implements GetOptionsInterface { + public forceFetch: boolean = false; + public maxAge: number = DEFAULT_MAX_AGE_SECS; + public sdkOptions?: unknown; + public transform?: TransformOptions; + + public constructor(options: GetOptionsInterface = {}) { + Object.assign(this, options); + } +} + +export { + GetOptions +}; \ No newline at end of file diff --git a/packages/parameters/src/constants.ts b/packages/parameters/src/constants.ts new file mode 100644 index 0000000000..b9cd5beeb3 --- /dev/null +++ b/packages/parameters/src/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_MAX_AGE_SECS = 5; +export const TRANSFORM_METHOD_JSON = 'json'; +export const TRANSFORM_METHOD_BINARY = 'binary'; \ No newline at end of file diff --git a/packages/parameters/src/index.ts b/packages/parameters/src/index.ts new file mode 100644 index 0000000000..4fdc288aba --- /dev/null +++ b/packages/parameters/src/index.ts @@ -0,0 +1,2 @@ +export * from './BaseProvider'; +export * from './Exceptions'; diff --git a/packages/parameters/src/types/BaseProvider.ts b/packages/parameters/src/types/BaseProvider.ts new file mode 100644 index 0000000000..8f6754d2d3 --- /dev/null +++ b/packages/parameters/src/types/BaseProvider.ts @@ -0,0 +1,34 @@ +type TransformOptions = 'auto' | 'binary' | 'json'; + +interface GetOptionsInterface { + maxAge?: number + forceFetch?: boolean + sdkOptions?: unknown + transform?: TransformOptions +} + +interface GetMultipleOptionsInterface { + maxAge?: number + forceFetch?: boolean + sdkOptions?: unknown + transform?: string + throwOnTransformError?: boolean +} + +interface ExpirableValueInterface { + value: string | Record + ttl: number +} + +interface BaseProviderInterface { + get(name: string, options?: GetOptionsInterface): Promise> + getMultiple(path: string, options?: GetMultipleOptionsInterface): Promise> +} + +export { + GetOptionsInterface, + GetMultipleOptionsInterface, + BaseProviderInterface, + ExpirableValueInterface, + TransformOptions, +}; \ No newline at end of file diff --git a/packages/parameters/src/types/index.ts b/packages/parameters/src/types/index.ts new file mode 100644 index 0000000000..73eb846a94 --- /dev/null +++ b/packages/parameters/src/types/index.ts @@ -0,0 +1 @@ +export * from './BaseProvider'; \ No newline at end of file diff --git a/packages/parameters/tests/helpers/populateEnvironmentVariables.ts b/packages/parameters/tests/helpers/populateEnvironmentVariables.ts new file mode 100644 index 0000000000..e808ac84b9 --- /dev/null +++ b/packages/parameters/tests/helpers/populateEnvironmentVariables.ts @@ -0,0 +1,9 @@ +// Reserved variables +process.env._X_AMZN_TRACE_ID = 'Root=1-5759e988-bd862e3fe1be46a994272793;Parent=557abcec3ee5a047;Sampled=1'; +process.env.AWS_LAMBDA_FUNCTION_NAME = 'my-lambda-function'; +process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '128'; +process.env.AWS_REGION = 'eu-west-1'; + +// Powertools variables +process.env.LOG_LEVEL = 'DEBUG'; +process.env.POWERTOOLS_SERVICE_NAME = 'hello-world'; diff --git a/packages/parameters/tests/unit/BaseProvider.test.ts b/packages/parameters/tests/unit/BaseProvider.test.ts new file mode 100644 index 0000000000..8b7e0e3f78 --- /dev/null +++ b/packages/parameters/tests/unit/BaseProvider.test.ts @@ -0,0 +1,436 @@ +/** + * Test BaseProvider class + * + * @group unit/parameters/baseProvider/class + */ + +import { BaseProvider, ExpirableValue, GetParameterError, TransformParameterError } from '../../src'; +import { toBase64 } from '@aws-sdk/util-base64-node'; + +const encoder = new TextEncoder(); + +describe('Class: BaseProvider', () => { + + afterEach(() => { + jest.clearAllMocks(); + }); + + class TestProvider extends BaseProvider { + public _add(key: string, value: ExpirableValue): void { + this.store.set(key, value); + } + + public _get(_name: string): Promise { + throw Error('Not implemented.'); + } + + public _getKeyTest(key: string): ExpirableValue | undefined { + return this.store.get(key); + } + + public _getMultiple(_path: string): Promise> { + throw Error('Not implemented.'); + } + + public _getStoreSize(): number { + return this.store.size; + } + } + + describe('Method: addToCache', () => { + + test('when called with a value and maxAge equal to 0, it skips the cache entirely', () => { + + // Prepare + const provider = new TestProvider(); + + // Act + provider.addToCache('my-key', 'value', 0); + + // Assess + expect(provider._getKeyTest('my-key')).toBeUndefined(); + + }); + + test('when called with a value and maxAge, it places the value in the cache', () => { + + // Prepare + const provider = new TestProvider(); + + // Act + provider.addToCache('my-key', 'my-value', 5000); + + // Assess + expect(provider._getKeyTest('my-key')).toEqual(expect.objectContaining({ + value: 'my-value' + })); + + }); + + }); + + describe('Method: get', () => { + + test('when the underlying _get method throws an error, it throws a GetParameterError', async () => { + + // Prepare + const provider = new TestProvider(); + + // Act / Assess + await expect(provider.get('my-parameter')).rejects.toThrowError(GetParameterError); + + }); + + test('when called and a cached value is available, it returns an the cached value', async () => { + + // Prepare + const provider = new TestProvider(); + provider._add([ 'my-parameter', undefined ].toString(), new ExpirableValue('my-value', 5000)); + + // Act + const values = await provider.get('my-parameter'); + + // Assess + expect(values).toEqual('my-value'); + + }); + + test('when called with forceFetch, even whith cached value available, it returns the remote value', async () => { + + // Prepare + const mockData = 'my-remote-value'; + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + provider._add([ 'my-parameter', undefined ].toString(), new ExpirableValue('my-value', 5000)); + + // Act + const values = await provider.get('my-parameter', { forceFetch: true }); + + // Assess + expect(values).toEqual('my-remote-value'); + + }); + + test('when called and values cached are expired, it returns the remote values', async () => { + + // Prepare + const mockData = 'my-remote-value'; + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + const expirableValue = new ExpirableValue('my-other-value', 0); + jest.spyOn(expirableValue, 'isExpired').mockImplementation(() => true); + provider._add([ 'my-path', undefined ].toString(), expirableValue); + + // Act + const values = await provider.get('my-parameter'); + + // Assess + expect(values).toEqual('my-remote-value'); + + }); + + test('when called with a json transform, and the value is a valid string representation of a JSON, it returns an object', async () => { + + // Prepare + const mockData = JSON.stringify({ foo: 'bar' }); + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const value = await provider.get('my-parameter', { transform: 'json' }); + + // Assess + expect(typeof value).toBe('object'); + expect(value).toMatchObject({ + 'foo': 'bar' + }); + + }); + + test('when called with a json transform, and the value is NOT a valid string representation of a JSON, it throws', async () => { + + // Prepare + const mockData = `${JSON.stringify({ foo: 'bar' })}{`; + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act / Assess + await expect(provider.get('my-parameter', { transform: 'json' })).rejects.toThrowError(TransformParameterError); + + }); + + test('when called with a binary transform, and the value is a valid string representation of a binary, it returns the decoded value', async () => { + // Prepare + const mockData = toBase64(encoder.encode('my-value')); + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const value = await provider.get('my-parameter', { transform: 'binary' }); + + // Assess + expect(typeof value).toBe('string'); + expect(value).toEqual('my-value'); + + }); + + test('when called with a binary transform, and the value is NOT a valid string representation of a binary, it throws', async () => { + + // Prepare + const mockData = 'qw'; + const provider = new TestProvider(); + jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act / Assess + await expect(provider.get('my-parameter', { transform: 'binary' })).rejects.toThrowError(TransformParameterError); + + }); + + }); + + describe('Method: getMultiple', () => { + test('when the underlying _getMultiple throws an error, it throws a GetParameterError', async () => { + + // Prepare + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((_resolve, reject) => reject(new Error('Some error.')))); + + // Act / Assess + await expect(provider.getMultiple('my-parameter')).rejects.toThrowError(GetParameterError); + + }); + + test('when called with a json transform, and all the values are a valid string representation of a JSON, it returns an object with all the values', async () => { + + // Prepare + const mockData = { 'A': JSON.stringify({ foo: 'bar' }) }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'json' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': { + 'foo': 'bar' + } + }); + + }); + + test('when called, it returns an object with the values', async () => { + + // Prepare + const mockData = { 'A': 'foo', 'B': 'bar' }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path'); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': 'foo', + 'B': 'bar' + }); + + }); + + test('when called with a json transform, and one of the values is NOT a valid string representation of a JSON, it returns an object with partial failures', async () => { + + // Prepare + const mockData = { 'A': JSON.stringify({ foo: 'bar' }), 'B': `${JSON.stringify({ foo: 'bar' })}{` }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'json' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': { + 'foo': 'bar' + }, + 'B': undefined + }); + + }); + + test('when called with a json transform and throwOnTransformError equal to TRUE, and at least ONE the values is NOT a valid string representation of a JSON, it throws', async () => { + + // Prepare + const mockData = { 'A': `${JSON.stringify({ foo: 'bar' })}{` }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act / Assess + await expect(provider.getMultiple('my-path', { transform: 'json', throwOnTransformError: true })).rejects.toThrowError(TransformParameterError); + + }); + + test('when called with a binary transform, and all the values are a valid string representation of a binary, it returns an object with all the values', async () => { + + // Prepare + const mockData = { 'A': toBase64(encoder.encode('my-value')).toString() }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'binary' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': 'my-value' + }); + + }); + + test('when called with a binary transform, and one of the values is NOT a valid string representation of a binary, it returns an object with partial failures', async () => { + + // Prepare + const mockData = { 'A': toBase64(encoder.encode('my-value')), 'B': 'qw' }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'binary' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': 'my-value', + 'B': undefined + }); + + }); + + test('when called with a binary transform and throwOnTransformError equal to TRUE, and at least ONE the values is NOT a valid string representation of a binary, it throws', async () => { + + // Prepare + const mockData = { 'A': 'qw' }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act / Assess + await expect(provider.getMultiple('my-path', { transform: 'binary', throwOnTransformError: true })).rejects.toThrowError(TransformParameterError); + + }); + + test('when called with auto transform and the key of the parameter ends with `.binary`, and all the values are a valid string representation of a binary, it returns an object with all the transformed values', async () => { + + // Prepare + const mockData = { 'A.binary': toBase64(encoder.encode('my-value')) }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'auto' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A.binary': 'my-value' + }); + + }); + + test('when called with auto transform and the key of the parameter DOES NOT end with `.binary` or `.json`, it returns an object with all the values NOT transformed', async () => { + + // Prepare + const mockBinary = toBase64(encoder.encode('my-value')); + const mockData = { 'A.foo': mockBinary }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'auto' }); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A.foo': mockBinary + }); + + }); + + test('when called with a binary transform, and at least ONE the values is undefined, it returns an object with one of the values undefined', async () => { + + // Prepare + const mockData = { 'A': undefined }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + + // Act + const values = await provider.getMultiple('my-path', { transform: 'auto' }); + + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': undefined + }); + + }); + + test('when called and values cached are available, it returns an object with the cached values', async () => { + + // Prepare + const provider = new TestProvider(); + provider._add([ 'my-path', undefined ].toString(), new ExpirableValue({ 'A': 'my-value' }, 60000)); + + // Act + const values = await provider.getMultiple('my-path'); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': 'my-value', + }); + + }); + + test('when called and values cached are expired, it returns an object with the remote values', async () => { + + // Prepare + const mockData = { 'A': 'my-value' }; + const provider = new TestProvider(); + jest.spyOn(provider, '_getMultiple').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData))); + const expirableValue = new ExpirableValue({ 'B': 'my-other-value' }, 0); + jest.spyOn(expirableValue, 'isExpired').mockImplementation(() => true); + provider._add([ 'my-path', undefined ].toString(), expirableValue); + + // Act + const values = await provider.getMultiple('my-path'); + + // Assess + expect(typeof values).toBe('object'); + expect(values).toMatchObject({ + 'A': 'my-value', + }); + + }); + + }); + + describe('Method: clearCache', () => { + + test('when called, it clears the store', () => { + + // Prepare + const provider = new TestProvider(); + provider._add([ 'my-path', undefined ].toString(), new ExpirableValue({ 'B': 'my-other-value' }, 0)); + + // Act + provider.clearCache(); + + // Assess + expect(provider._getStoreSize()).toBe(0); + + }); + + }); + +}); \ No newline at end of file diff --git a/packages/parameters/tsconfig-dev.json b/packages/parameters/tsconfig-dev.json new file mode 100644 index 0000000000..7c6046c8bc --- /dev/null +++ b/packages/parameters/tsconfig-dev.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "noImplicitAny": true, + "target": "ES2019", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "outDir": "lib", + "strict": true, + "inlineSourceMap": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "pretty": true, + "baseUrl": "src/", + "rootDirs": [ "src/" ] + }, + "include": [ "src/**/*", "examples/**/*", "**/tests/**/*" ], + "exclude": [ "./node_modules"], + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "fallbackPolling": "dynamicPriority" + }, + "lib": [ "es2019" ], + "types": [ + "jest", + "node" + ] +} \ No newline at end of file diff --git a/packages/parameters/tsconfig.es.json b/packages/parameters/tsconfig.es.json new file mode 100644 index 0000000000..7c6046c8bc --- /dev/null +++ b/packages/parameters/tsconfig.es.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "noImplicitAny": true, + "target": "ES2019", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "outDir": "lib", + "strict": true, + "inlineSourceMap": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "pretty": true, + "baseUrl": "src/", + "rootDirs": [ "src/" ] + }, + "include": [ "src/**/*", "examples/**/*", "**/tests/**/*" ], + "exclude": [ "./node_modules"], + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "fallbackPolling": "dynamicPriority" + }, + "lib": [ "es2019" ], + "types": [ + "jest", + "node" + ] +} \ No newline at end of file diff --git a/packages/parameters/tsconfig.json b/packages/parameters/tsconfig.json new file mode 100644 index 0000000000..3d7d8b8b05 --- /dev/null +++ b/packages/parameters/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "noImplicitAny": true, + "target": "ES2019", + "module": "commonjs", + "declaration": true, + "outDir": "lib", + "strict": true, + "inlineSourceMap": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "pretty": true, + "baseUrl": "src/", + "rootDirs": [ "src/" ], + "esModuleInterop": true + }, + "include": [ "src/**/*" ], + "exclude": [ "./node_modules"], + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "fallbackPolling": "dynamicPriority" + }, + "lib": [ "es2019" ], + "types": [ + "jest", + "node" + ] +} \ No newline at end of file