From d717a26bba086be4c01f1458422662f8bfba09a9 Mon Sep 17 00:00:00 2001
From: Andrea Amorosi <dreamorosi@gmail.com>
Date: Mon, 21 Nov 2022 11:07:36 +0100
Subject: [PATCH] feat(parameters): added `BaseProvider` class (#1168)

* feat: added baseprovider class

* tests: added unit tests

* chore: added package to unit tests run

* chore: added comments

* chore: break down classes in files

* chore: remove redundant store init value

* refactor: moved constants in separate file

* chore: made store protected

* fix: removed cache pruning

* feat: added baseprovider class
---
 ...sable-run-linting-check-and-unit-tests.yml |   6 +-
 package.json                                  |   3 +-
 packages/parameters/jest.config.js            |  45 ++
 packages/parameters/package.json              |  54 +++
 packages/parameters/src/BaseProvider.ts       | 170 +++++++
 packages/parameters/src/Exceptions.ts         |  14 +
 packages/parameters/src/ExpirableValue.ts     |  20 +
 packages/parameters/src/GetMultipleOptions.ts |  18 +
 packages/parameters/src/GetOptions.ts         |  17 +
 packages/parameters/src/constants.ts          |   3 +
 packages/parameters/src/index.ts              |   2 +
 packages/parameters/src/types/BaseProvider.ts |  34 ++
 packages/parameters/src/types/index.ts        |   1 +
 .../helpers/populateEnvironmentVariables.ts   |   9 +
 .../tests/unit/BaseProvider.test.ts           | 436 ++++++++++++++++++
 packages/parameters/tsconfig-dev.json         |  30 ++
 packages/parameters/tsconfig.es.json          |  30 ++
 packages/parameters/tsconfig.json             |  30 ++
 18 files changed, 918 insertions(+), 4 deletions(-)
 create mode 100644 packages/parameters/jest.config.js
 create mode 100644 packages/parameters/package.json
 create mode 100644 packages/parameters/src/BaseProvider.ts
 create mode 100644 packages/parameters/src/Exceptions.ts
 create mode 100644 packages/parameters/src/ExpirableValue.ts
 create mode 100644 packages/parameters/src/GetMultipleOptions.ts
 create mode 100644 packages/parameters/src/GetOptions.ts
 create mode 100644 packages/parameters/src/constants.ts
 create mode 100644 packages/parameters/src/index.ts
 create mode 100644 packages/parameters/src/types/BaseProvider.ts
 create mode 100644 packages/parameters/src/types/index.ts
 create mode 100644 packages/parameters/tests/helpers/populateEnvironmentVariables.ts
 create mode 100644 packages/parameters/tests/unit/BaseProvider.test.ts
 create mode 100644 packages/parameters/tsconfig-dev.json
 create mode 100644 packages/parameters/tsconfig.es.json
 create mode 100644 packages/parameters/tsconfig.json

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': [
+    '<rootDir>/src',
+    '<rootDir>/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': [
+    '<rootDir>/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<string, ExpirableValue>;
+
+  public constructor () {
+    this.store = new Map();
+  }
+
+  public addToCache(key: string, value: string | Record<string, unknown>, 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<undefined | string | Record<string, unknown>> {
+    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<undefined | Record<string, unknown>> {
+    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<string, unknown>;
+    }
+
+    let values: Record<string, unknown> = {};
+    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<string | undefined>;
+
+  protected abstract _getMultiple(path: string, sdkOptions?: unknown): Promise<Record<string, string|undefined>>;
+
+  /**
+   * 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<string, unknown> | 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<string, unknown>;
+    } 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<string, unknown>, transform: TransformOptions, throwOnTransformError: boolean): Record<string, unknown> => {
+  const transformedValues: Record<string, unknown> = {};
+  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<string, unknown>;
+
+  public constructor(value: string | Record<string, unknown>, 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<string, unknown>
+  ttl: number
+}
+
+interface BaseProviderInterface {
+  get(name: string, options?: GetOptionsInterface): Promise<undefined | string | Record<string, unknown>>
+  getMultiple(path: string, options?: GetMultipleOptionsInterface): Promise<void | Record<string, unknown>>
+}
+
+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<string> {
+      throw Error('Not implemented.');
+    }
+
+    public _getKeyTest(key: string): ExpirableValue | undefined {
+      return this.store.get(key);
+    }
+    
+    public _getMultiple(_path: string): Promise<Record<string, string | undefined>> {
+      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