@featbit/openfeature-provider-node-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import { FbProvider } from "../src";
2
+ import { Client, ErrorCode, OpenFeature, ProviderEvents, ProviderStatus } from "@openfeature/server-sdk";
3
+ import { IClientContext, IFbClient, integrations, ReasonKinds, IFallthrough, IStore } from "@featbit/node-server-sdk";
4
+ import { translateContext } from "../src/translateContext";
5
+
6
+ it('can be initialized', async () => {
7
+ const logger = new integrations.TestLogger();
8
+ const provider = new FbProvider({ sdkKey: 'sdk-key', offline: true, logger });
9
+ await provider.initialize({});
10
+
11
+ expect(provider.status).toEqual(ProviderStatus.READY);
12
+ expect(logger.logs.length).toEqual(2);
13
+ expect(logger.logs).toEqual(['Offline mode enabled. No data synchronization with the FeatBit server will occur.', 'FbClient started successfully.']);
14
+ await provider.onClose();
15
+ });
16
+
17
+ it('can fail to initialize client', async () => {
18
+ const logger = new integrations.TestLogger();
19
+ const provider = new FbProvider({
20
+ sdkKey: 'sdk-key',
21
+ logger,
22
+ streamingUri: 'ws://localhost:6001',
23
+ eventsUri: 'http://localhost:6001',
24
+ dataSynchronizer: (
25
+ clientContext: IClientContext,
26
+ store: IStore,
27
+ dataSourceUpdates: any,
28
+ initSuccessHandler: VoidFunction,
29
+ errorHandler?: (e: Error) => void,
30
+ ) => ({
31
+ start: () => {
32
+ setTimeout(() => errorHandler?.({ code: 401 } as any), 20);
33
+ },
34
+ })
35
+ });
36
+ try {
37
+ await provider.initialize({});
38
+ } catch (e) {
39
+ expect((e as Error).message).toEqual('Authentication failed. Double check your SDK key.');
40
+ }
41
+ expect(provider.status).toEqual(ProviderStatus.ERROR);
42
+ });
43
+
44
+ it('emits events for flag changes', async () => {
45
+ const logger = new integrations.TestLogger();
46
+ const td = new integrations.TestData();
47
+ const provider = new FbProvider( {
48
+ sdkKey: 'sdk-key',
49
+ logger,
50
+ dataSynchronizer: td.getFactory(),
51
+ });
52
+ let count = 0;
53
+ provider.events.addHandler(ProviderEvents.ConfigurationChanged, (eventDetail) => {
54
+ expect(eventDetail?.flagsChanged).toEqual(['flagA']);
55
+ count += 1;
56
+ });
57
+
58
+ const fallthrough: IFallthrough = {
59
+ dispatchKey: "keyId",
60
+ includedInExpt: true,
61
+ variations: [
62
+ {
63
+ id: "trueId",
64
+ exptRollout: 1,
65
+ rollout: [0, 1]
66
+ }
67
+ ]
68
+ }
69
+
70
+ const flag = new integrations.FlagBuilder()
71
+ .key('flagA')
72
+ .isEnabled(true)
73
+ .disabledVariationId('falseId')
74
+ .fallthrough(fallthrough)
75
+ .variations([{id: 'trueId', value: 'true'}, {id: 'falseId', value: 'false'}])
76
+ .version(1)
77
+ .build();
78
+
79
+ await td.update(flag);
80
+ expect(await provider.getClient()
81
+ .stringVariation('flagA', { key: 'test-key' }, 'false'))
82
+ .toEqual('true');
83
+ expect(count).toEqual(1);
84
+ await provider.onClose();
85
+ });
86
+
87
+ describe('given a mock FbClient', () => {
88
+ const logger: integrations.TestLogger = new integrations.TestLogger();
89
+ let provider: FbProvider;
90
+ let fbClient: IFbClient;
91
+ let openFeatureClient: Client;
92
+ const basicContext = { targetingKey: 'the-key' };
93
+ const testFlagKey = 'a-key';
94
+ let testFlagBuilder;
95
+
96
+ const td = new integrations.TestData();
97
+ const factory = td.getFactory();
98
+
99
+ beforeEach(() => {
100
+ testFlagBuilder = new integrations.FlagBuilder()
101
+ .key(testFlagKey)
102
+ .disabledVariationId('invalidId')
103
+ .fallthrough({
104
+ dispatchKey: "keyId",
105
+ includedInExpt: true,
106
+ variations: [
107
+ {
108
+ id: "trueId",
109
+ exptRollout: 1,
110
+ rollout: [0, 1]
111
+ }
112
+ ]
113
+ })
114
+ .targetUsers([{keyIds: [basicContext.targetingKey], variationId: 'trueId'}])
115
+ .variations([{id: 'trueId', value: 'true'}, {id: 'falseId', value: 'false'}, {id: 'invalidId', value: 'badness'}])
116
+ .version(1);
117
+
118
+ provider = new FbProvider({
119
+ sdkKey: 'sdk-key',
120
+ logger,
121
+ streamingUri: 'ws://localhost:5100',
122
+ eventsUri: 'http://localhost:5100',
123
+ dataSynchronizer: factory,
124
+ });
125
+
126
+ fbClient = provider.getClient();
127
+ OpenFeature.setProvider(provider);
128
+ openFeatureClient = OpenFeature.getClient();
129
+ logger.reset();
130
+ });
131
+
132
+ afterEach(() => {
133
+ jest.resetAllMocks();
134
+ })
135
+
136
+ afterAll(async () => {
137
+ await provider.onClose();
138
+ });
139
+ // String variations
140
+ it('calls the client correctly for string variations', async () => {
141
+ fbClient.evaluateCore<string> = jest.fn( () => ({
142
+ kind: ReasonKinds.Off,
143
+ reason: '',
144
+ value: 'some value'
145
+ }));
146
+ await openFeatureClient.getStringDetails(testFlagKey, 'default', basicContext);
147
+ expect(fbClient.evaluateCore)
148
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 'default', expect.anything());
149
+ jest.clearAllMocks();
150
+ await openFeatureClient.getStringValue(testFlagKey, 'default', basicContext);
151
+ expect(fbClient.evaluateCore)
152
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 'default', expect.anything());
153
+ });
154
+
155
+ it('handles correct return types for string variations', async () => {
156
+ await td.update(testFlagBuilder.isEnabled(true).build());
157
+ const res = await openFeatureClient.getStringDetails(testFlagKey, 'default', basicContext);
158
+ expect(res).toMatchObject({
159
+ flagKey: testFlagKey,
160
+ value: 'true',
161
+ reason: ReasonKinds.TargetMatch,
162
+ });
163
+ });
164
+
165
+ // Boolean variations
166
+ it('calls the client correctly for boolean variations', async () => {
167
+ fbClient.evaluateCore<boolean> = jest.fn( () => ({
168
+ kind: ReasonKinds.Off,
169
+ reason: '',
170
+ value: true
171
+ }));
172
+ await openFeatureClient.getBooleanDetails(testFlagKey, false, basicContext);
173
+ expect(fbClient.evaluateCore)
174
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), false, expect.anything());
175
+ jest.clearAllMocks();
176
+ await openFeatureClient.getBooleanValue(testFlagKey, false, basicContext);
177
+ expect(fbClient.evaluateCore)
178
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), false, expect.anything());
179
+ });
180
+
181
+ it('handles correct return types for boolean variations', async () => {
182
+ await td.update(testFlagBuilder.isEnabled(true).build());
183
+ const res = await openFeatureClient.getBooleanDetails(testFlagKey, false, basicContext);
184
+ expect(res).toMatchObject({
185
+ flagKey: testFlagKey,
186
+ value: true,
187
+ reason: ReasonKinds.TargetMatch,
188
+ });
189
+ });
190
+
191
+ it('handles incorrect return types for boolean variations', async () => {
192
+ await td.update(testFlagBuilder.isEnabled(false).build());
193
+ const res = await openFeatureClient.getBooleanDetails(testFlagKey, false, basicContext);
194
+ expect(res).toMatchObject({
195
+ flagKey: testFlagKey,
196
+ value: false,
197
+ reason: 'ERROR',
198
+ errorCode: 'TYPE_MISMATCH',
199
+ });
200
+ });
201
+
202
+ // Numeric variations
203
+ it('calls the client correctly for numeric variations', async () => {
204
+ fbClient.evaluateCore<number> = jest.fn( () => ({
205
+ kind: ReasonKinds.Off,
206
+ reason: '',
207
+ value: 1
208
+ }));
209
+ await openFeatureClient.getNumberDetails(testFlagKey, 0, basicContext);
210
+ expect(fbClient.evaluateCore)
211
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 0, expect.anything());
212
+ jest.clearAllMocks();
213
+ await openFeatureClient.getNumberValue(testFlagKey, 0, basicContext);
214
+ expect(fbClient.evaluateCore)
215
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), 0, expect.anything());
216
+ });
217
+
218
+ it('handles correct return types for numeric variations', async () => {
219
+ await td.update(testFlagBuilder.variations([{id: 'invalidId', value: '1'}]).isEnabled(false).build());
220
+ const res = await openFeatureClient.getNumberDetails(testFlagKey, 0, basicContext);
221
+ expect(res).toMatchObject({
222
+ flagKey: testFlagKey,
223
+ value: 1,
224
+ reason: ReasonKinds.Off
225
+ });
226
+ });
227
+
228
+ it('handles incorrect return types for numeric variations', async () => {
229
+ await td.update(testFlagBuilder.isEnabled(true).build());
230
+ const res = await openFeatureClient.getNumberDetails(testFlagKey, 0, basicContext);
231
+ expect(res).toMatchObject({
232
+ flagKey: testFlagKey,
233
+ value: 0,
234
+ reason: 'ERROR',
235
+ errorCode: 'TYPE_MISMATCH',
236
+ });
237
+ });
238
+
239
+ // JSON variations
240
+ it('calls the client correctly for object variations', async () => {
241
+ fbClient.evaluateCore<any> = jest.fn( () => ({
242
+ kind: ReasonKinds.Off,
243
+ reason: '',
244
+ value: { some: 'value' }
245
+ }));
246
+ await openFeatureClient.getObjectDetails(testFlagKey, {}, basicContext);
247
+ expect(fbClient.evaluateCore)
248
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), {}, expect.anything());
249
+ jest.clearAllMocks();
250
+ await openFeatureClient.getObjectValue(testFlagKey, {}, basicContext);
251
+ expect(fbClient.evaluateCore)
252
+ .toHaveBeenCalledWith(testFlagKey, translateContext(logger, basicContext), {}, expect.anything());
253
+ });
254
+
255
+ it('handles correct return types for object variations', async () => {
256
+ await td.update(testFlagBuilder.variations([{id: 'invalidId', value: '{"some": "value"}'}]).isEnabled(false).build());
257
+ const res = await openFeatureClient.getObjectDetails(testFlagKey, {}, basicContext);
258
+ expect(res).toMatchObject({
259
+ flagKey: testFlagKey,
260
+ value: { some: 'value' },
261
+ reason: ReasonKinds.Off
262
+ });
263
+ });
264
+
265
+ it('handles incorrect return types for object variations', async () => {
266
+ await td.update(testFlagBuilder.isEnabled(false).build());
267
+ const res = await openFeatureClient.getObjectDetails(testFlagKey, {}, basicContext);
268
+ expect(res).toMatchObject({
269
+ flagKey: testFlagKey,
270
+ value: {},
271
+ reason: 'ERROR',
272
+ errorCode: 'TYPE_MISMATCH',
273
+ });
274
+ });
275
+
276
+ it('not existing flag', async () => {
277
+ const flagKey = 'not_existing_flag';
278
+ const res = await openFeatureClient.getObjectDetails(flagKey, {}, basicContext);
279
+ expect(res).toMatchObject({
280
+ flagKey,
281
+ value: {},
282
+ reason: 'ERROR',
283
+ errorCode: ErrorCode.FLAG_NOT_FOUND,
284
+ });
285
+ });
286
+
287
+ it('logs information about missing keys', async () => {
288
+ await openFeatureClient.getObjectDetails(testFlagKey, {}, {});
289
+ expect(logger.logs[0]).toEqual("The EvaluationContext must contain either a 'targetingKey' "
290
+ + "or a 'key' and the type must be a string.");
291
+ });
292
+ });
293
+
294
+
@@ -0,0 +1,36 @@
1
+ import SafeLogger from "../src/SafeLogger";
2
+ import { BasicLogger, ILogger } from "@featbit/node-server-sdk";
3
+
4
+ it('throws when constructed with an invalid logger', () => {
5
+ expect(
6
+ () => new SafeLogger({} as ILogger, new BasicLogger({})),
7
+ ).toThrow();
8
+ });
9
+
10
+ describe('given a logger that throws in logs', () => {
11
+ const strings: string[] = [];
12
+ const logger = new SafeLogger({
13
+ info: () => { throw new Error('info'); },
14
+ debug: () => { throw new Error('info'); },
15
+ warn: () => { throw new Error('info'); },
16
+ error: () => { throw new Error('info'); },
17
+ }, new BasicLogger({
18
+ level: 'debug',
19
+ destination: (...args: any) => {
20
+ strings.push(args.join(' '));
21
+ },
22
+ }));
23
+
24
+ it('uses the fallback logger', () => {
25
+ logger.debug('a');
26
+ logger.info('b');
27
+ logger.warn('c');
28
+ logger.error('d');
29
+ expect(strings).toEqual([
30
+ 'debug: [FeatBit] a',
31
+ 'info: [FeatBit] b',
32
+ 'warn: [FeatBit] c',
33
+ 'error: [FeatBit] d',
34
+ ]);
35
+ });
36
+ });
@@ -0,0 +1,26 @@
1
+ // import { ILogger } from "@featbit/node-server-sdk";
2
+ //
3
+ //
4
+ // export default class TestLogger implements ILogger {
5
+ // public logs: string[] = [];
6
+ //
7
+ // error(...args: any[]): void {
8
+ // this.logs.push(args.join(' '));
9
+ // }
10
+ //
11
+ // warn(...args: any[]): void {
12
+ // this.logs.push(args.join(' '));
13
+ // }
14
+ //
15
+ // info(...args: any[]): void {
16
+ // this.logs.push(args.join(' '));
17
+ // }
18
+ //
19
+ // debug(...args: any[]): void {
20
+ // this.logs.push(args.join(' '));
21
+ // }
22
+ //
23
+ // reset() {
24
+ // this.logs = [];
25
+ // }
26
+ // }
@@ -0,0 +1,102 @@
1
+ import { translateContext } from "../src/translateContext";
2
+ import { integrations } from "@featbit/node-server-sdk";
3
+
4
+ const testLogger = new integrations.TestLogger();
5
+ it('Uses the targetingKey as the user key', () => {
6
+ expect(translateContext(testLogger, { targetingKey: 'the-key' })).toEqual({ key: 'the-key', name: '', customizedProperties: [] });
7
+ });
8
+
9
+ it('Uses the name as the user name', () => {
10
+ expect(translateContext(testLogger, { name: 'the-key' })).toEqual({ key: '', name: 'the-key', customizedProperties: [] });
11
+ });
12
+
13
+ it('gives targetingKey precedence over key', () => {
14
+ expect(translateContext(
15
+ testLogger,
16
+ { targetingKey: 'target-key', key: 'key-key' },
17
+ )).toEqual({
18
+ key: 'target-key',
19
+ name: '',
20
+ customizedProperties: []
21
+ });
22
+ });
23
+
24
+ describe.each([
25
+ ['firstName', 'value3'],
26
+ ['lastName', 'value4'],
27
+ ['email', 'value5'],
28
+ ['avatar', 'value6'],
29
+ ['ip', 'value7'],
30
+ ['country', 'value8'],
31
+ ['anonymous', true],
32
+ ])('given custom attributes', (key, value) => {
33
+ it('accepts the custom attribute as customized property correctly', () => {
34
+ expect(translateContext(
35
+ testLogger,
36
+ { targetingKey: 'the-key', name: 'abc', [key]: value },
37
+ )).toEqual({
38
+ key: 'the-key',
39
+ name: 'abc',
40
+ customizedProperties: [{
41
+ name: key, value
42
+ }]
43
+ });
44
+ });
45
+ });
46
+
47
+ it('Accepts array custom as customized properties', () => {
48
+ const context = {
49
+ key: 'the-key',
50
+ custom: [{ name: 'custom1', value: 'value1' }, { name: 'custom2', value: 'value2' }]
51
+ };
52
+
53
+ expect(translateContext(
54
+ testLogger,
55
+ context,
56
+ )).toEqual({
57
+ key: 'the-key',
58
+ name: '',
59
+ customizedProperties: context.custom
60
+ });
61
+ });
62
+
63
+ it('Accepts object custom as customized properties', () => {
64
+ const context = {
65
+ key: 'the-key',
66
+ custom: {
67
+ custom1: 'value1',
68
+ custom2: 'value2'
69
+ }
70
+ };
71
+
72
+ expect(translateContext(
73
+ testLogger,
74
+ context,
75
+ )).toEqual({
76
+ key: 'the-key',
77
+ name: '',
78
+ customizedProperties: [
79
+ {
80
+ name: 'custom1',
81
+ value: 'value1'
82
+ },
83
+ {
84
+ name: 'custom2',
85
+ value: 'value2'
86
+ }
87
+ ]
88
+ });
89
+ });
90
+
91
+ it.each(['key', 'targetingKey'])('handles key or targetingKey', (key) => {
92
+ expect(translateContext(
93
+ testLogger,
94
+ { [key]: 'the-key' },
95
+ )).toEqual({
96
+ key: 'the-key',
97
+ name: '',
98
+ customizedProperties: []
99
+ });
100
+ });
101
+
102
+
@@ -0,0 +1,51 @@
1
+ import { translateResult } from "../src/translateResult";
2
+ import { IEvalDetail, ReasonKinds } from "@featbit/node-server-sdk"
3
+ import { ErrorCode, StandardResolutionReasons } from "@openfeature/server-sdk";
4
+
5
+ it.each([
6
+ true,
7
+ 'potato',
8
+ 42,
9
+ { yes: 'no' },
10
+ ])('puts the value into the result.', (value) => {
11
+ const evalDetail: IEvalDetail<typeof value> = {
12
+ value,
13
+ kind: ReasonKinds.FallThrough,
14
+ }
15
+
16
+ expect(translateResult<typeof value>(evalDetail).value).toEqual(value);
17
+ });
18
+
19
+ it.each([
20
+ ReasonKinds.Off,
21
+ ReasonKinds.FallThrough,
22
+ ReasonKinds.TargetMatch,
23
+ ReasonKinds.RuleMatch
24
+ ])('populates the resolution reason with kind', (kind: ReasonKinds) => {
25
+ expect(translateResult<boolean>({
26
+ value: true,
27
+ kind
28
+ }).reason).toEqual(kind);
29
+ });
30
+
31
+ it.each([
32
+ [ReasonKinds.WrongType, ErrorCode.TYPE_MISMATCH],
33
+ [ReasonKinds.Error, ErrorCode.GENERAL],
34
+ [ReasonKinds.ClientNotReady, ErrorCode.PROVIDER_NOT_READY],
35
+ ])('populates the resolution reason with error', (kind: ReasonKinds, expectedErrorCode) => {
36
+ const result = translateResult<boolean>({
37
+ value: true,
38
+ kind
39
+ });
40
+
41
+ expect(result.reason).toEqual(StandardResolutionReasons.ERROR);
42
+ expect(result.errorCode).toEqual(expectedErrorCode);
43
+ });
44
+
45
+ it('does not populate the errorCode when there is not an error', () => {
46
+ const translated = translateResult<boolean>({
47
+ value: true,
48
+ kind: ReasonKinds.FallThrough,
49
+ });
50
+ expect(translated.errorCode).toBeUndefined();
51
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "include": ["src/**/*"],
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "esModuleInterop": true,
6
+ "target": "es6",
7
+ "moduleResolution": "node",
8
+ "sourceMap": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "declarationMap": true // enables importers to jump to source
13
+ },
14
+ "lib": ["es2015"]
15
+ }