Skip to content

Commit fecedb9

Browse files
authored
feat(parameters): AppConfigProvider (#1200)
1 parent 2e4bb76 commit fecedb9

File tree

8 files changed

+1384
-207
lines changed

8 files changed

+1384
-207
lines changed

Diff for: package-lock.json

+887-207
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: packages/parameters/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@
5454
"nodejs"
5555
],
5656
"devDependencies": {
57+
"@aws-sdk/client-appconfigdata": "^3.241.0",
5758
"@aws-sdk/client-secrets-manager": "^3.238.0",
5859
"@aws-sdk/client-ssm": "^3.244.0",
60+
"@aws-sdk/types": "^3.226.0",
5961
"aws-sdk-client-mock": "^2.0.1",
6062
"aws-sdk-client-mock-jest": "^2.0.1"
6163
}
6264
}
65+
+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { BaseProvider, DEFAULT_PROVIDERS } from '../BaseProvider';
2+
import {
3+
AppConfigDataClient,
4+
StartConfigurationSessionCommand,
5+
GetLatestConfigurationCommand,
6+
} from '@aws-sdk/client-appconfigdata';
7+
import type { StartConfigurationSessionCommandInput } from '@aws-sdk/client-appconfigdata';
8+
import type {
9+
AppConfigProviderOptions,
10+
AppConfigGetOptionsInterface,
11+
} from '../types/AppConfigProvider';
12+
13+
class AppConfigProvider extends BaseProvider {
14+
public client: AppConfigDataClient;
15+
protected configurationTokenStore: Map<string, string> = new Map();
16+
private application?: string;
17+
private environment: string;
18+
19+
/**
20+
* It initializes the AppConfigProvider class'.
21+
* *
22+
* @param {AppConfigProviderOptions} options
23+
*/
24+
public constructor(options: AppConfigProviderOptions) {
25+
super();
26+
this.client = new AppConfigDataClient(options.clientConfig || {});
27+
if (!options?.application && !process.env['POWERTOOLS_SERVICE_NAME']) {
28+
throw new Error(
29+
'Application name is not defined or POWERTOOLS_SERVICE_NAME is not set'
30+
);
31+
}
32+
this.application =
33+
options.application || process.env['POWERTOOLS_SERVICE_NAME'];
34+
this.environment = options.environment;
35+
}
36+
37+
/**
38+
* Retrieve a configuration from AWS App config.
39+
*/
40+
public async get(
41+
name: string,
42+
options?: AppConfigGetOptionsInterface
43+
): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
44+
return super.get(name, options);
45+
}
46+
47+
/**
48+
* Retrieving multiple configurations is not supported by AWS App Config Provider.
49+
*/
50+
public async getMultiple(
51+
path: string,
52+
_options?: unknown
53+
): Promise<undefined | Record<string, unknown>> {
54+
return super.getMultiple(path);
55+
}
56+
57+
/**
58+
* Retrieve a configuration from AWS App config.
59+
*
60+
* @param {string} name - Name of the configuration
61+
* @param {AppConfigGetOptionsInterface} options - SDK options to propagate to `StartConfigurationSession` API call
62+
* @returns {Promise<Uint8Array | undefined>}
63+
*/
64+
protected async _get(
65+
name: string,
66+
options?: AppConfigGetOptionsInterface
67+
): Promise<Uint8Array | undefined> {
68+
69+
/**
70+
* The new AppConfig APIs require two API calls to return the configuration
71+
* First we start the session and after that we retrieve the configuration
72+
* We need to store { name: token } pairs to use in the next execution
73+
**/
74+
75+
if (!this.configurationTokenStore.has(name)) {
76+
77+
const sessionOptions: StartConfigurationSessionCommandInput = {
78+
...(options?.sdkOptions || {}),
79+
ApplicationIdentifier: this.application,
80+
ConfigurationProfileIdentifier: name,
81+
EnvironmentIdentifier: this.environment,
82+
};
83+
84+
const sessionCommand = new StartConfigurationSessionCommand(
85+
sessionOptions
86+
);
87+
88+
const session = await this.client.send(sessionCommand);
89+
90+
if (!session.InitialConfigurationToken) throw new Error('Unable to retrieve the configuration token');
91+
92+
this.configurationTokenStore.set(name, session.InitialConfigurationToken);
93+
}
94+
95+
const getConfigurationCommand = new GetLatestConfigurationCommand({
96+
ConfigurationToken: this.configurationTokenStore.get(name),
97+
});
98+
99+
const response = await this.client.send(getConfigurationCommand);
100+
101+
if (response.NextPollConfigurationToken) {
102+
this.configurationTokenStore.set(name, response.NextPollConfigurationToken);
103+
} else {
104+
this.configurationTokenStore.delete(name);
105+
}
106+
107+
return response.Configuration;
108+
}
109+
110+
/**
111+
* Retrieving multiple configurations is not supported by AWS App Config Provider API.
112+
*
113+
* @throws Not Implemented Error.
114+
*/
115+
protected async _getMultiple(
116+
_path: string,
117+
_sdkOptions?: unknown
118+
): Promise<Record<string, string | undefined>> {
119+
return this._notImplementedError();
120+
}
121+
122+
private _notImplementedError(): never {
123+
throw new Error('Not Implemented');
124+
}
125+
}
126+
127+
export { AppConfigProvider, DEFAULT_PROVIDERS };

Diff for: packages/parameters/src/appconfig/getAppConfig.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { AppConfigProvider, DEFAULT_PROVIDERS } from './AppConfigProvider';
2+
import type { GetAppConfigCombinedInterface } from '../types/AppConfigProvider';
3+
4+
/**
5+
* Gets the AppConfig data for the specified name.
6+
*
7+
* @param {string} name - The configuration profile ID or the configuration profile name.
8+
* @param {GetAppConfigCombinedInterface} options - Options for the AppConfigProvider and the get method.
9+
* @returns {Promise<undefined | string | Uint8Array | Record<string, unknown>>} A promise that resolves to the AppConfig data or undefined if not found.
10+
*/
11+
const getAppConfig = (
12+
name: string,
13+
options: GetAppConfigCombinedInterface
14+
): Promise<undefined | string | Uint8Array | Record<string, unknown>> => {
15+
if (!DEFAULT_PROVIDERS.hasOwnProperty('appconfig')) {
16+
DEFAULT_PROVIDERS.appconfig = new AppConfigProvider(options);
17+
}
18+
19+
return DEFAULT_PROVIDERS.appconfig.get(name, options);
20+
};
21+
22+
export { getAppConfig };

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

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

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

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type {
2+
AppConfigDataClientConfig,
3+
StartConfigurationSessionCommandInput,
4+
} from '@aws-sdk/client-appconfigdata';
5+
import type { GetOptionsInterface } from 'types/BaseProvider';
6+
7+
/**
8+
* Options for the AppConfigProvider class constructor.
9+
*
10+
* @interface AppConfigProviderOptions
11+
* @property {string} environment - The environment ID or the environment name.
12+
* @property {string} [application] - The application ID or the application name.
13+
* @property {AppConfigDataClientConfig} [clientConfig] - Optional configuration to pass during client initialization, e.g. AWS region.
14+
*/
15+
interface AppConfigProviderOptions {
16+
environment: string
17+
application?: string
18+
clientConfig?: AppConfigDataClientConfig
19+
}
20+
21+
/**
22+
* Options for the AppConfigProvider get method.
23+
*
24+
* @interface AppConfigGetOptionsInterface
25+
* @extends {GetOptionsInterface}
26+
* @property {StartConfigurationSessionCommandInput} [sdkOptions] - Required options to start configuration session.
27+
*/
28+
interface AppConfigGetOptionsInterface extends Omit<GetOptionsInterface, 'sdkOptions'> {
29+
sdkOptions?: Omit<
30+
Partial<StartConfigurationSessionCommandInput>,
31+
| 'ApplicationIdentifier'
32+
| 'EnvironmentIdentifier | ConfigurationProfileIdentifier'
33+
>
34+
}
35+
36+
/**
37+
* Combined options for the getAppConfig utility function.
38+
*
39+
* @interface getAppConfigCombinedInterface
40+
* @extends {AppConfigProviderOptions, AppConfigGetOptionsInterface}
41+
*/
42+
interface GetAppConfigCombinedInterface
43+
extends Omit<AppConfigProviderOptions, 'clientConfig'>,
44+
AppConfigGetOptionsInterface {}
45+
46+
export {
47+
AppConfigProviderOptions,
48+
AppConfigGetOptionsInterface,
49+
GetAppConfigCombinedInterface,
50+
};
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Test AppConfigProvider class
3+
*
4+
* @group unit/parameters/AppConfigProvider/class
5+
*/
6+
import { AppConfigProvider } from '../../src/appconfig/index';
7+
8+
import {
9+
AppConfigDataClient,
10+
StartConfigurationSessionCommand,
11+
GetLatestConfigurationCommand,
12+
} from '@aws-sdk/client-appconfigdata';
13+
import { mockClient } from 'aws-sdk-client-mock';
14+
import 'aws-sdk-client-mock-jest';
15+
import { AppConfigProviderOptions } from '../../src/types/AppConfigProvider';
16+
17+
describe('Class: AppConfigProvider', () => {
18+
const client = mockClient(AppConfigDataClient);
19+
const encoder = new TextEncoder();
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
describe('Method: _get', () => {
26+
test('when called with name and options, it returns binary configuration', async () => {
27+
// Prepare
28+
const options: AppConfigProviderOptions = {
29+
application: 'MyApp',
30+
environment: 'MyAppProdEnv',
31+
};
32+
const provider = new AppConfigProvider(options);
33+
const name = 'MyAppFeatureFlag';
34+
35+
const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
36+
const fakeNextToken = 'bmV4dFRva2Vu';
37+
const mockData = encoder.encode('myAppConfiguration');
38+
39+
client
40+
.on(StartConfigurationSessionCommand)
41+
.resolves({
42+
InitialConfigurationToken: fakeInitialToken,
43+
})
44+
.on(GetLatestConfigurationCommand)
45+
.resolves({
46+
Configuration: mockData,
47+
NextPollConfigurationToken: fakeNextToken,
48+
});
49+
50+
// Act
51+
const result = await provider.get(name);
52+
53+
// Assess
54+
expect(result).toBe(mockData);
55+
});
56+
57+
test('when called without application option, it will be retrieved from POWERTOOLS_SERVICE_NAME and provider successfully return configuration', async () => {
58+
// Prepare
59+
process.env.POWERTOOLS_SERVICE_NAME = 'MyApp';
60+
const config = {
61+
environment: 'MyAppProdEnv',
62+
};
63+
const provider = new AppConfigProvider(config);
64+
const name = 'MyAppFeatureFlag';
65+
66+
const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
67+
const fakeNextToken = 'bmV4dFRva2Vu';
68+
const mockData = encoder.encode('myAppConfiguration');
69+
70+
client
71+
.on(StartConfigurationSessionCommand)
72+
.resolves({
73+
InitialConfigurationToken: fakeInitialToken,
74+
})
75+
.on(GetLatestConfigurationCommand)
76+
.resolves({
77+
Configuration: mockData,
78+
NextPollConfigurationToken: fakeNextToken,
79+
});
80+
81+
// Act
82+
const result = await provider.get(name);
83+
84+
// Assess
85+
expect(result).toBe(mockData);
86+
});
87+
88+
test('when called without application option and POWERTOOLS_SERVICE_NAME is not set, it throws an Error', async () => {
89+
// Prepare
90+
process.env.POWERTOOLS_SERVICE_NAME = '';
91+
const options = {
92+
environment: 'MyAppProdEnv',
93+
};
94+
95+
// Act & Assess
96+
expect(() => {
97+
new AppConfigProvider(options);
98+
}).toThrow();
99+
});
100+
101+
test('when configuration response doesn\'t have the next token it should force a new session by removing the stored token', async () => {
102+
// Prepare
103+
class AppConfigProviderMock extends AppConfigProvider {
104+
public _addToStore(key: string, value: string): void {
105+
this.configurationTokenStore.set(key, value);
106+
}
107+
public _storeHas(key: string): boolean {
108+
return this.configurationTokenStore.has(key);
109+
}
110+
}
111+
112+
const options: AppConfigProviderOptions = {
113+
application: 'MyApp',
114+
environment: 'MyAppProdEnv',
115+
};
116+
const provider = new AppConfigProviderMock(options);
117+
const name = 'MyAppFeatureFlag';
118+
const fakeToken = 'ZmFrZVRva2Vu';
119+
const mockData = encoder.encode('myAppConfiguration');
120+
121+
client.on(GetLatestConfigurationCommand).resolves({
122+
Configuration: mockData,
123+
NextPollConfigurationToken: undefined,
124+
});
125+
126+
// Act
127+
provider._addToStore(name, fakeToken);
128+
await provider.get(name);
129+
130+
// Assess
131+
expect(provider._storeHas(name)).toBe(false);
132+
});
133+
134+
test('when session response doesn\'t have an initial token, it throws an error', async () => {
135+
// Prepare
136+
const options: AppConfigProviderOptions = {
137+
application: 'MyApp',
138+
environment: 'MyAppProdEnv',
139+
};
140+
const provider = new AppConfigProvider(options);
141+
const name = 'MyAppFeatureFlag';
142+
143+
client.on(StartConfigurationSessionCommand).resolves({
144+
InitialConfigurationToken: undefined,
145+
});
146+
147+
// Act & Assess
148+
await expect(provider.get(name)).rejects.toThrow();
149+
});
150+
});
151+
152+
describe('Method: _getMultiple', () => {
153+
test('when called it throws an Error, because this method is not supported by AppConfig API', async () => {
154+
// Prepare
155+
const config = {
156+
application: 'MyApp',
157+
environment: 'MyAppProdEnv',
158+
};
159+
const path = '/my/path';
160+
const provider = new AppConfigProvider(config);
161+
const errorMessage = 'Not Implemented';
162+
163+
// Act & Assess
164+
await expect(provider.getMultiple(path)).rejects.toThrow(errorMessage);
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)