From 96b66d96c151b8a4a89ae705c18baeb6f4423dc6 Mon Sep 17 00:00:00 2001 From: Cody Greene Date: Fri, 8 Dec 2023 22:16:00 -0800 Subject: [PATCH] add getIdentityFlagsSync() --- sdk/index.ts | 67 ++++++++++++++++++++++ sdk/polling_manager.ts | 2 + sdk/types.ts | 2 +- tests/sdk/flagsmith-cache.test.ts | 60 ++++++++++++++++++- tests/sdk/flagsmith-identity-flags.test.ts | 43 ++++++++++++++ tests/sdk/utils.ts | 17 ++++++ 6 files changed, 189 insertions(+), 2 deletions(-) diff --git a/sdk/index.ts b/sdk/index.ts index bd45f4d..ed438a2 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -55,6 +55,8 @@ export class Flagsmith { private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void; private analyticsProcessor?: AnalyticsProcessor; private logger: Logger; + /** True if the environment has loaded at least once */ + private ready: boolean; /** * A Flagsmith client. * @@ -114,6 +116,7 @@ export class Flagsmith { this.logger = data.logger || pino(); this.offlineMode = data.offlineMode || false; this.offlineHandler = data.offlineHandler; + this.ready = this.offlineMode // argument validation if (this.offlineMode && !this.offlineHandler) { @@ -235,6 +238,52 @@ export class Flagsmith { return this.getIdentityFlagsFromApi(identifier, traits); } + /** + * Get all the flags for the current environment for a given identity. + * Local evaluation mode only. If using a custom cache, then it should + * return synchronously as well. + * + * @param {string} identifier a unique identifier for the identity in the + * current environment, e.g. email address, username, uuid + * @param {{[key:string]:any}} traits? a dictionary of traits to add / + * update on the identity in Flagsmith, e.g. {"num_orders": 10} + * @returns Flags object holding all the flags for the given identity. + */ + getIdentityFlagsSync(identifier: string, traits?: { [key: string]: any }): Flags { + if (!identifier) { + throw new Error('`identifier` argument is missing or invalid.'); + } + if (!this.enableLocalEvaluation && !this.offlineMode) { + throw new Error('local evaluation and/or offline mode is not enabled'); + } + if (!this.ready) { + throw new Error('environment not loaded yet; try again later') + } + const cachedItem = !!this.cache && (this.cache.get(`flags-${identifier}`)); + if (!!cachedItem) { + if (cachedItem instanceof Flags) { + return cachedItem; + } + throw new Error('cache returned a Promise in sync mode'); + } + const identityModel = this.buildIdentityModel( + identifier, + Object.entries(traits || {}).map(([key, value]) => ({ key, value })) + ); + const featureStates = getIdentityFeatureStates(this.environment, identityModel); + const flags = Flags.fromFeatureStateModels({ + featureStates: featureStates, + analyticsProcessor: this.analyticsProcessor, + defaultFlagHandler: this.defaultFlagHandler, + identityID: identityModel.djangoID || identityModel.compositeKey + }); + if (!!this.cache) { + // @ts-ignore node-cache types are incorrect, ttl should be optional + this.cache.set(`flags-${identifier}`, flags); + } + return flags; + } + /** * Get the segments for the current environment for a given identity. Will also upsert all traits to the Flagsmith API for future evaluations. Providing a @@ -296,6 +345,7 @@ export class Flagsmith { this.environment.identityOverrides.map(identity => [identity.identifier, identity] )); } + this.ready = true; if (this.onEnvironmentChange) { this.onEnvironmentChange(null, this.environment); } @@ -306,6 +356,23 @@ export class Flagsmith { } } + /** + * Wait for the environment document to load. If the request fails then + * this promise is rejected. Useful for local evaluation mode. + */ + async readyCheck(): Promise { + if (this.ready) { return } + if (!this.environmentDataPollingManager || this.environmentDataPollingManager.isStopped) { + throw new Error('polling manager is stopped') + } + if (!this.environmentPromise) { + // This should never throw, but await anyway to avoid a dangling + // promise + await this.updateEnvironment() + } + await this.environmentPromise + } + async close() { this.environmentDataPollingManager?.stop(); } diff --git a/sdk/polling_manager.ts b/sdk/polling_manager.ts index 1fbf78c..06e28d3 100644 --- a/sdk/polling_manager.ts +++ b/sdk/polling_manager.ts @@ -27,4 +27,6 @@ export class EnvironmentDataPollingManager { } clearInterval(this.interval); } + + get isStopped() { return !!this.interval } } diff --git a/sdk/types.ts b/sdk/types.ts index e854917..cdde3e5 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -5,7 +5,7 @@ import { Logger } from "pino"; import { BaseOfflineHandler } from "./offline_handlers"; export interface FlagsmithCache { - get(key: string): Promise | undefined; + get(key: string): Promise | Flags | undefined; set(key: string, value: Flags, ttl: string | number): boolean | Promise; has(key: string): boolean | Promise; [key: string]: any; diff --git a/tests/sdk/flagsmith-cache.test.ts b/tests/sdk/flagsmith-cache.test.ts index 1cea4e9..0b6a9b0 100644 --- a/tests/sdk/flagsmith-cache.test.ts +++ b/tests/sdk/flagsmith-cache.test.ts @@ -1,5 +1,13 @@ import fetch, { Headers } from 'node-fetch'; -import { environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils'; +import { + TestCache, + TestCacheSync, + environmentJSON, + environmentModel, + flagsJSON, + flagsmith, + identitiesJSON, +} from './utils'; jest.mock('node-fetch'); jest.mock('../../sdk/polling_manager'); @@ -148,3 +156,53 @@ test('test_cache_used_for_identity_flags_local_evaluation', async () => { }); test('test_cache_used_for_all_flags', async () => { }); + +test('test_cache_used_for_identity_flags_sync', async () => { + // @ts-expect-error jest mocks not added to typedef + fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON()))); + + const cache = new TestCacheSync(); + const set = jest.spyOn(cache, 'set'); + + const identifier = 'identifier'; + const traits = { some_trait: 'some_value' }; + const flg = flagsmith({ + cache, + environmentKey: 'ser.key', + enableLocalEvaluation: true, + }); + + await flg.readyCheck(); + + flg.getIdentityFlagsSync(identifier, traits).allFlags(); + const identityFlags = flg.getIdentityFlagsSync(identifier, traits).allFlags(); + + expect(set).toBeCalled(); + expect(cache.has('flags-identifier')).toBe(true); + + expect(fetch).toBeCalledTimes(1); + + expect(identityFlags[0].enabled).toBe(true); + expect(identityFlags[0].value).toBe('some-value'); + expect(identityFlags[0].featureName).toBe('some_feature'); +}); + +test('test_cache_used_for_identity_flags_sync_error', async () => { + // @ts-expect-error jest mocks not added to typedef + fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON()))); + + const cache = new TestCache(); + const identifier = 'identifier'; + + const flg = flagsmith({ + cache, + environmentKey: 'ser.key', + enableLocalEvaluation: true, + }); + + await flg.readyCheck(); + + expect(() => { + flg.getIdentityFlagsSync(identifier); + }).toThrow('returned a Promise'); +}); diff --git a/tests/sdk/flagsmith-identity-flags.test.ts b/tests/sdk/flagsmith-identity-flags.test.ts index b680022..02fab45 100644 --- a/tests/sdk/flagsmith-identity-flags.test.ts +++ b/tests/sdk/flagsmith-identity-flags.test.ts @@ -155,3 +155,46 @@ test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar'); expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false); }); + +test('test_get_identity_flags_sync_basic', async () => { + // @ts-ignore + fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON()))); + const identifier = 'identifier'; + + const flg = flagsmith({ + environmentKey: 'ser.key', + enableLocalEvaluation: true, + }); + + await flg.readyCheck(); + + const identityFlags = flg.getIdentityFlagsSync(identifier); + + expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar'); + expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false); +}); + +test('test_get_identity_flags_sync_errors', async () => { + // @ts-ignore + fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON()))); + + const flg = flagsmith({ + environmentKey: 'ser.key', + enableLocalEvaluation: true, + }); + expect(() => { + flg.getIdentityFlagsSync('example'); + }).toThrow('not loaded yet'); + await flg.readyCheck(); + expect(() => { + flg.getIdentityFlagsSync(''); + }).toThrow('missing or invalid'); + + const f2 = flagsmith({ + environmentKey: 'ser.key', + enableLocalEvaluation: false, + }); + expect(() => { + f2.getIdentityFlagsSync('example'); + }).toThrow('not enabled'); +}); diff --git a/tests/sdk/utils.ts b/tests/sdk/utils.ts index e8897f8..0188702 100644 --- a/tests/sdk/utils.ts +++ b/tests/sdk/utils.ts @@ -24,6 +24,23 @@ export class TestCache implements FlagsmithCache { } } +export class TestCacheSync implements FlagsmithCache { + cache: Record = {}; + + get(name: string): Flags|undefined { + return this.cache[name]; + } + + has(name: string): boolean { + return !!this.cache[name]; + } + + set(name: string, value: Flags, ttl: number|string): boolean { + this.cache[name] = value; + return true + } +} + export function analyticsProcessor() { return new AnalyticsProcessor({ environmentKey: 'test-key',