Skip to content

Commit

Permalink
feat(parameters): AppConfigProvider (#1200)
Browse files Browse the repository at this point in the history
  • Loading branch information
shdq committed Jan 6, 2023
1 parent 2e4bb76 commit fecedb9
Show file tree
Hide file tree
Showing 8 changed files with 1,384 additions and 207 deletions.
1,094 changes: 887 additions & 207 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/parameters/package.json
Expand Up @@ -54,9 +54,12 @@
"nodejs"
],
"devDependencies": {
"@aws-sdk/client-appconfigdata": "^3.241.0",
"@aws-sdk/client-secrets-manager": "^3.238.0",
"@aws-sdk/client-ssm": "^3.244.0",
"@aws-sdk/types": "^3.226.0",
"aws-sdk-client-mock": "^2.0.1",
"aws-sdk-client-mock-jest": "^2.0.1"
}
}

127 changes: 127 additions & 0 deletions packages/parameters/src/appconfig/AppConfigProvider.ts
@@ -0,0 +1,127 @@
import { BaseProvider, DEFAULT_PROVIDERS } from '../BaseProvider';
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
} from '@aws-sdk/client-appconfigdata';
import type { StartConfigurationSessionCommandInput } from '@aws-sdk/client-appconfigdata';
import type {
AppConfigProviderOptions,
AppConfigGetOptionsInterface,
} from '../types/AppConfigProvider';

class AppConfigProvider extends BaseProvider {
public client: AppConfigDataClient;
protected configurationTokenStore: Map<string, string> = new Map();
private application?: string;
private environment: string;

/**
* It initializes the AppConfigProvider class'.
* *
* @param {AppConfigProviderOptions} options
*/
public constructor(options: AppConfigProviderOptions) {
super();
this.client = new AppConfigDataClient(options.clientConfig || {});
if (!options?.application && !process.env['POWERTOOLS_SERVICE_NAME']) {
throw new Error(
'Application name is not defined or POWERTOOLS_SERVICE_NAME is not set'
);
}
this.application =
options.application || process.env['POWERTOOLS_SERVICE_NAME'];
this.environment = options.environment;
}

/**
* Retrieve a configuration from AWS App config.
*/
public async get(
name: string,
options?: AppConfigGetOptionsInterface
): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
return super.get(name, options);
}

/**
* Retrieving multiple configurations is not supported by AWS App Config Provider.
*/
public async getMultiple(
path: string,
_options?: unknown
): Promise<undefined | Record<string, unknown>> {
return super.getMultiple(path);
}

/**
* Retrieve a configuration from AWS App config.
*
* @param {string} name - Name of the configuration
* @param {AppConfigGetOptionsInterface} options - SDK options to propagate to `StartConfigurationSession` API call
* @returns {Promise<Uint8Array | undefined>}
*/
protected async _get(
name: string,
options?: AppConfigGetOptionsInterface
): Promise<Uint8Array | undefined> {

/**
* The new AppConfig APIs require two API calls to return the configuration
* First we start the session and after that we retrieve the configuration
* We need to store { name: token } pairs to use in the next execution
**/

if (!this.configurationTokenStore.has(name)) {

const sessionOptions: StartConfigurationSessionCommandInput = {
...(options?.sdkOptions || {}),
ApplicationIdentifier: this.application,
ConfigurationProfileIdentifier: name,
EnvironmentIdentifier: this.environment,
};

const sessionCommand = new StartConfigurationSessionCommand(
sessionOptions
);

const session = await this.client.send(sessionCommand);

if (!session.InitialConfigurationToken) throw new Error('Unable to retrieve the configuration token');

this.configurationTokenStore.set(name, session.InitialConfigurationToken);
}

const getConfigurationCommand = new GetLatestConfigurationCommand({
ConfigurationToken: this.configurationTokenStore.get(name),
});

const response = await this.client.send(getConfigurationCommand);

if (response.NextPollConfigurationToken) {
this.configurationTokenStore.set(name, response.NextPollConfigurationToken);
} else {
this.configurationTokenStore.delete(name);
}

return response.Configuration;
}

/**
* Retrieving multiple configurations is not supported by AWS App Config Provider API.
*
* @throws Not Implemented Error.
*/
protected async _getMultiple(
_path: string,
_sdkOptions?: unknown
): Promise<Record<string, string | undefined>> {
return this._notImplementedError();
}

private _notImplementedError(): never {
throw new Error('Not Implemented');
}
}

export { AppConfigProvider, DEFAULT_PROVIDERS };
22 changes: 22 additions & 0 deletions packages/parameters/src/appconfig/getAppConfig.ts
@@ -0,0 +1,22 @@
import { AppConfigProvider, DEFAULT_PROVIDERS } from './AppConfigProvider';
import type { GetAppConfigCombinedInterface } from '../types/AppConfigProvider';

/**
* Gets the AppConfig data for the specified name.
*
* @param {string} name - The configuration profile ID or the configuration profile name.
* @param {GetAppConfigCombinedInterface} options - Options for the AppConfigProvider and the get method.
* @returns {Promise<undefined | string | Uint8Array | Record<string, unknown>>} A promise that resolves to the AppConfig data or undefined if not found.
*/
const getAppConfig = (
name: string,
options: GetAppConfigCombinedInterface
): Promise<undefined | string | Uint8Array | Record<string, unknown>> => {
if (!DEFAULT_PROVIDERS.hasOwnProperty('appconfig')) {
DEFAULT_PROVIDERS.appconfig = new AppConfigProvider(options);
}

return DEFAULT_PROVIDERS.appconfig.get(name, options);
};

export { getAppConfig };
2 changes: 2 additions & 0 deletions packages/parameters/src/appconfig/index.ts
@@ -0,0 +1,2 @@
export * from './AppConfigProvider';
export * from './getAppConfig';
50 changes: 50 additions & 0 deletions packages/parameters/src/types/AppConfigProvider.ts
@@ -0,0 +1,50 @@
import type {
AppConfigDataClientConfig,
StartConfigurationSessionCommandInput,
} from '@aws-sdk/client-appconfigdata';
import type { GetOptionsInterface } from 'types/BaseProvider';

/**
* Options for the AppConfigProvider class constructor.
*
* @interface AppConfigProviderOptions
* @property {string} environment - The environment ID or the environment name.
* @property {string} [application] - The application ID or the application name.
* @property {AppConfigDataClientConfig} [clientConfig] - Optional configuration to pass during client initialization, e.g. AWS region.
*/
interface AppConfigProviderOptions {
environment: string
application?: string
clientConfig?: AppConfigDataClientConfig
}

/**
* Options for the AppConfigProvider get method.
*
* @interface AppConfigGetOptionsInterface
* @extends {GetOptionsInterface}
* @property {StartConfigurationSessionCommandInput} [sdkOptions] - Required options to start configuration session.
*/
interface AppConfigGetOptionsInterface extends Omit<GetOptionsInterface, 'sdkOptions'> {
sdkOptions?: Omit<
Partial<StartConfigurationSessionCommandInput>,
| 'ApplicationIdentifier'
| 'EnvironmentIdentifier | ConfigurationProfileIdentifier'
>
}

/**
* Combined options for the getAppConfig utility function.
*
* @interface getAppConfigCombinedInterface
* @extends {AppConfigProviderOptions, AppConfigGetOptionsInterface}
*/
interface GetAppConfigCombinedInterface
extends Omit<AppConfigProviderOptions, 'clientConfig'>,
AppConfigGetOptionsInterface {}

export {
AppConfigProviderOptions,
AppConfigGetOptionsInterface,
GetAppConfigCombinedInterface,
};
167 changes: 167 additions & 0 deletions packages/parameters/tests/unit/AppConfigProvider.test.ts
@@ -0,0 +1,167 @@
/**
* Test AppConfigProvider class
*
* @group unit/parameters/AppConfigProvider/class
*/
import { AppConfigProvider } from '../../src/appconfig/index';

import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
} from '@aws-sdk/client-appconfigdata';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest';
import { AppConfigProviderOptions } from '../../src/types/AppConfigProvider';

describe('Class: AppConfigProvider', () => {
const client = mockClient(AppConfigDataClient);
const encoder = new TextEncoder();

beforeEach(() => {
jest.clearAllMocks();
});

describe('Method: _get', () => {
test('when called with name and options, it returns binary configuration', async () => {
// Prepare
const options: AppConfigProviderOptions = {
application: 'MyApp',
environment: 'MyAppProdEnv',
};
const provider = new AppConfigProvider(options);
const name = 'MyAppFeatureFlag';

const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
const fakeNextToken = 'bmV4dFRva2Vu';
const mockData = encoder.encode('myAppConfiguration');

client
.on(StartConfigurationSessionCommand)
.resolves({
InitialConfigurationToken: fakeInitialToken,
})
.on(GetLatestConfigurationCommand)
.resolves({
Configuration: mockData,
NextPollConfigurationToken: fakeNextToken,
});

// Act
const result = await provider.get(name);

// Assess
expect(result).toBe(mockData);
});

test('when called without application option, it will be retrieved from POWERTOOLS_SERVICE_NAME and provider successfully return configuration', async () => {
// Prepare
process.env.POWERTOOLS_SERVICE_NAME = 'MyApp';
const config = {
environment: 'MyAppProdEnv',
};
const provider = new AppConfigProvider(config);
const name = 'MyAppFeatureFlag';

const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
const fakeNextToken = 'bmV4dFRva2Vu';
const mockData = encoder.encode('myAppConfiguration');

client
.on(StartConfigurationSessionCommand)
.resolves({
InitialConfigurationToken: fakeInitialToken,
})
.on(GetLatestConfigurationCommand)
.resolves({
Configuration: mockData,
NextPollConfigurationToken: fakeNextToken,
});

// Act
const result = await provider.get(name);

// Assess
expect(result).toBe(mockData);
});

test('when called without application option and POWERTOOLS_SERVICE_NAME is not set, it throws an Error', async () => {
// Prepare
process.env.POWERTOOLS_SERVICE_NAME = '';
const options = {
environment: 'MyAppProdEnv',
};

// Act & Assess
expect(() => {
new AppConfigProvider(options);
}).toThrow();
});

test('when configuration response doesn\'t have the next token it should force a new session by removing the stored token', async () => {
// Prepare
class AppConfigProviderMock extends AppConfigProvider {
public _addToStore(key: string, value: string): void {
this.configurationTokenStore.set(key, value);
}
public _storeHas(key: string): boolean {
return this.configurationTokenStore.has(key);
}
}

const options: AppConfigProviderOptions = {
application: 'MyApp',
environment: 'MyAppProdEnv',
};
const provider = new AppConfigProviderMock(options);
const name = 'MyAppFeatureFlag';
const fakeToken = 'ZmFrZVRva2Vu';
const mockData = encoder.encode('myAppConfiguration');

client.on(GetLatestConfigurationCommand).resolves({
Configuration: mockData,
NextPollConfigurationToken: undefined,
});

// Act
provider._addToStore(name, fakeToken);
await provider.get(name);

// Assess
expect(provider._storeHas(name)).toBe(false);
});

test('when session response doesn\'t have an initial token, it throws an error', async () => {
// Prepare
const options: AppConfigProviderOptions = {
application: 'MyApp',
environment: 'MyAppProdEnv',
};
const provider = new AppConfigProvider(options);
const name = 'MyAppFeatureFlag';

client.on(StartConfigurationSessionCommand).resolves({
InitialConfigurationToken: undefined,
});

// Act & Assess
await expect(provider.get(name)).rejects.toThrow();
});
});

describe('Method: _getMultiple', () => {
test('when called it throws an Error, because this method is not supported by AppConfig API', async () => {
// Prepare
const config = {
application: 'MyApp',
environment: 'MyAppProdEnv',
};
const path = '/my/path';
const provider = new AppConfigProvider(config);
const errorMessage = 'Not Implemented';

// Act & Assess
await expect(provider.getMultiple(path)).rejects.toThrow(errorMessage);
});
});
});

0 comments on commit fecedb9

Please sign in to comment.