Skip to content

feat(parameters): AppConfigProvider #1200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
689 changes: 687 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions packages/parameters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,11 @@
"secrets",
"serverless",
"nodejs"
]
}
],
"devDependencies": {
"@aws-sdk/client-appconfigdata": "^3.241.0",
"@aws-sdk/types": "^3.226.0",
"aws-sdk-client-mock": "^2.0.1",
"aws-sdk-client-mock-jest": "^2.0.1"
}
}
115 changes: 115 additions & 0 deletions packages/parameters/src/AppConfigProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { BaseProvider, DEFAULT_PROVIDERS } from './BaseProvider';
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
} from '@aws-sdk/client-appconfigdata';
import type {
StartConfigurationSessionCommandInput,
GetLatestConfigurationCommandInput,
} from '@aws-sdk/client-appconfigdata';
import type { AppConfigGetOptionsInterface } from './types/AppConfigProvider';

class AppConfigProvider extends BaseProvider {
public client: AppConfigDataClient;
private application: string;
private environment: string;
private latestConfiguration: Uint8Array | undefined;
private token: string | undefined;

/**
* It initializes the AppConfigProvider class'.
* *
* @param {AppConfigGetOptionsInterface} config
*/
public constructor(options: AppConfigGetOptionsInterface) {
super();
this.client = new AppConfigDataClient(options.clientConfig || {});
this.application = options?.sdkOptions?.application || 'app_undefined'; // TODO: make it optional when we add retrieving from env var
this.environment = options?.sdkOptions?.environment || 'env_undefined';
}

public async get(name: string, options?: AppConfigGetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
return super.get(name, options);
}

/**
* Retrieve a parameter value 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 the token to use in the next execution
**/
if (!this.token) {
const sessionOptions: StartConfigurationSessionCommandInput = {
ConfigurationProfileIdentifier: name,
EnvironmentIdentifier: this.application,
ApplicationIdentifier: this.environment,
};

if (options?.sdkOptions) {
Object.assign(sessionOptions, options.sdkOptions);
}

const sessionCommand = new StartConfigurationSessionCommand(
sessionOptions
);

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

const getConfigurationCommand = new GetLatestConfigurationCommand({
ConfigurationToken: this.token,
});
const response = await this.client.send(getConfigurationCommand);

this.token = response.NextPollConfigurationToken;

const configuration = response.Configuration;

if (configuration) {
this.latestConfiguration = configuration;
}

return this.latestConfiguration;
}

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

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

const getAppConfig = (
name: string,
options: AppConfigGetOptionsInterface
): 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 { AppConfigProvider, getAppConfig };
4 changes: 3 additions & 1 deletion packages/parameters/src/BaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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';
import type { AppConfigGetOptionsInterface } from 'types/AppConfigProvider';

// These providers are dinamycally intialized on first use of the helper functions
const DEFAULT_PROVIDERS: Record<string, BaseProvider> = {};
Expand Down Expand Up @@ -38,8 +39,9 @@ abstract class BaseProvider implements BaseProviderInterface {
* 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
* @param {GetOptionsInterface | Partial<AppConfigGetOptionsInterface>} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch
*/
public async get(name: string, options?: Partial<AppConfigGetOptionsInterface>): Promise<undefined | string | Uint8Array | Record<string, unknown>>;
public async get(name: string, options?: GetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
const configs = new GetOptions(options);
const key = [ name, configs.transform ].toString();
Expand Down
23 changes: 23 additions & 0 deletions packages/parameters/src/types/AppConfigProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {
AppConfigDataClientConfig,
} from '@aws-sdk/client-appconfigdata';
import type { GetOptionsInterface } from 'types/BaseProvider';

/**
* Options for the AppConfigProvider get method.
*
* @interface AppConfigGetOptionsInterface
* @extends {GetOptionsInterface}
* @property {} [clientConfig] - optional configuration to pass during client initialization
* @property {} sdkOptions - required options to start configuration session.
*/
interface AppConfigGetOptionsInterface
extends Omit<GetOptionsInterface, 'sdkOptions'> {
clientConfig?: AppConfigDataClientConfig
sdkOptions?: {
application: string
environment: string
}
}

export { AppConfigGetOptionsInterface };
6 changes: 1 addition & 5 deletions packages/parameters/src/types/BaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ interface GetOptionsInterface {
transform?: TransformOptions
}

interface GetMultipleOptionsInterface {
maxAge?: number
forceFetch?: boolean
sdkOptions?: unknown
transform?: string
interface GetMultipleOptionsInterface extends GetOptionsInterface {
throwOnTransformError?: boolean
}

Expand Down
60 changes: 60 additions & 0 deletions packages/parameters/tests/unit/AppConfigProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Test AppConfigProvider class
*
* @group unit/parameters/AppConfigProvider/class
*/
import { AppConfigProvider } from '../../src/AppConfigProvider';
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
} from '@aws-sdk/client-appconfigdata';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest';

const encoder = new TextEncoder();

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

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

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

const mockInitialToken =
'AYADeNgfsRxdKiJ37A12OZ9vN2cAXwABABVhd3MtY3J5cHRvLXB1YmxpYy1rZXkAREF1RzlLMTg1Tkx2Wjk4OGV2UXkyQ1';
const mockNextToken =
'ImRmyljpZnxt7FfxeEOE5H8xQF1SfOlWZFnHujbzJmIvNeSAAA8/qA9ivK0ElRMwpvx96damGxt125XtMkmYf6a0OWSqnBw==';
const mockData = encoder.encode('myAppConfiguration');

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

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

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