diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index a0e3b50..ace9058 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -10,6 +10,7 @@ import { randomString } from '../src/util/randomstring'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { ExposureTrackingProvider } from '../src/types/exposure'; import { Exposure } from '../lib/typescript'; +import { FetchOptions } from '../src/types/client'; const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); @@ -392,3 +393,59 @@ test('ExperimentClient.variant experiment key passed from variant to exposure', client.variant('flagKey'); expect(didTrack).toEqual(true); }); + +const flagKeysTestVariantPartial = { + 'sdk-ci-test': serverVariant, +}; +const flagKeysTestVariants = { + 'sdk-ci-test-2': { payload: undefined, value: 'on', expKey: undefined }, + 'sdk-ci-test': serverVariant, +}; + +test('ExperimentClient.fetch with partial flag keys in fetch options, should return the fetched variant', async () => { + const client = new ExperimentClient(API_KEY, { + httpClient: new TestHttpClient(), + }); + const option: FetchOptions = { flagKeys: ['sdk-ci-test'] }; + await client.fetch(testUser, option); + const variants = client.all(); + expect(variants).toEqual(flagKeysTestVariantPartial); +}); + +test('ExperimentClient.fetch without fetch options, should return all variants', async () => { + const client = new ExperimentClient(API_KEY, { + debug: true, + httpClient: new TestHttpClient({ + status: 200, + body: JSON.stringify({ + 'sdk-ci-test': { key: 'on', payload: 'payload' }, + 'sdk-ci-test-2': { key: 'on' }, + }), + }), + }); + await client.fetch(testUser); + const variant = client.all(); + console.log(variant); + expect(variant).toEqual(flagKeysTestVariants); +}); + +test('ExperimentClient.fetch with not exist flagKeys in fetch options', async () => { + const client = new ExperimentClient(API_KEY, { + httpClient: new TestHttpClient({ status: 200, body: '{}' }), + }); + const option: FetchOptions = { flagKeys: ['123'] }; + await client.fetch(testUser, option); + const variant = client.all(); + expect(variant).toEqual({}); +}); + +test('existing storage variant removed when fetch without flag keys response stored', async () => { + const client = new ExperimentClient(API_KEY, { + httpClient: new TestHttpClient(), + }); + // @ts-ignore + client.storage.put('not-fetched-variant', { value: 'on' }); + await client.fetch(testUser); + const variant = client.variant('not-fetched-variant'); + expect(variant).toEqual({}); +}); diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 5922460..c87517a 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -8,7 +8,7 @@ import { version as PACKAGE_VERSION } from './gen/version'; import { ExperimentConfig, Defaults } from './types/config'; import { ConnectorUserProvider } from './integration/connector'; import { LocalStorage } from './storage/localStorage'; -import { Client } from './types/client'; +import { Client, FetchOptions } from './types/client'; import { ExposureTrackingProvider } from './types/exposure'; import { isFallback, Source, VariantSource } from './types/source'; import { Storage } from './types/storage'; @@ -91,19 +91,22 @@ export class ExperimentClient implements Client { * from the server, you generally do not need to call `fetch`. * * @param user The user to fetch variants for. + * @param options The {@link FetchOptions} for this specific request. * @returns Promise that resolves when the request for variants completes. * @see ExperimentUser * @see ExperimentUserProvider */ public async fetch( - user: ExperimentUser = this.user + user: ExperimentUser = this.user, + options?: FetchOptions ): Promise { this.setUser(user || {}); try { await this.fetchInternal( user, this.config.fetchTimeoutMillis, - this.config.retryFetchOnFailure + this.config.retryFetchOnFailure, + options ); } catch (e) { console.error(e); @@ -313,7 +316,8 @@ export class ExperimentClient implements Client { private async fetchInternal( user: ExperimentUser, timeoutMillis: number, - retry: boolean + retry: boolean, + options?: FetchOptions ): Promise { // Don't even try to fetch variants if API key is not set if (!this.apiKey) { @@ -329,12 +333,12 @@ export class ExperimentClient implements Client { } try { - const variants = await this.doFetch(user, timeoutMillis); - this.storeVariants(variants); + const variants = await this.doFetch(user, timeoutMillis, options); + this.storeVariants(variants, options); return variants; } catch (e) { if (retry) { - this.startRetries(user); + this.startRetries(user, options); } throw e; } @@ -342,7 +346,8 @@ export class ExperimentClient implements Client { private async doFetch( user: ExperimentUser, - timeoutMillis: number + timeoutMillis: number, + options?: FetchOptions ): Promise { const userContext = await this.addContextOrWait(user, 1000); const encodedContext = urlSafeBase64Encode(JSON.stringify(userContext)); @@ -355,6 +360,11 @@ export class ExperimentClient implements Client { 'Authorization': `Api-Key ${this.apiKey}`, 'X-Amp-Exp-User': encodedContext, }; + if (options && options.flagKeys) { + headers['X-Amp-Exp-Flag-Keys'] = urlSafeBase64Encode( + JSON.stringify(options.flagKeys) + ); + } this.debug('[Experiment] Fetch variants for user: ', userContext); const response = await this.httpClient.request( endpoint, @@ -384,16 +394,27 @@ export class ExperimentClient implements Client { return variants; } - private storeVariants(variants: Variants): void { - this.storage.clear(); + private storeVariants(variants: Variants, options?: FetchOptions): void { + let failedFlagKeys = options && options.flagKeys ? options.flagKeys : []; + if (failedFlagKeys.length === 0) { + this.storage.clear(); + } for (const key in variants) { + failedFlagKeys = failedFlagKeys.filter((flagKey) => flagKey !== key); this.storage.put(key, variants[key]); } + + for (const key in failedFlagKeys) { + this.storage.remove(key); + } this.storage.save(); this.debug('[Experiment] Stored variants: ', variants); } - private async startRetries(user: ExperimentUser): Promise { + private async startRetries( + user: ExperimentUser, + options?: FetchOptions + ): Promise { this.debug('[Experiment] Retry fetch'); this.retriesBackoff = new Backoff( fetchBackoffAttempts, @@ -402,7 +423,7 @@ export class ExperimentClient implements Client { fetchBackoffScalar ); this.retriesBackoff.start(async () => { - await this.fetchInternal(user, fetchBackoffTimeout, false); + await this.fetchInternal(user, fetchBackoffTimeout, false, options); }); } diff --git a/src/storage/localStorage.ts b/src/storage/localStorage.ts index 06d4293..2a0215a 100644 --- a/src/storage/localStorage.ts +++ b/src/storage/localStorage.ts @@ -32,6 +32,10 @@ export class LocalStorage implements Storage { this.map = {}; } + remove(key: string): void { + delete this.map[key]; + } + getAll(): Variants { return this.map; } diff --git a/src/types/client.ts b/src/types/client.ts index 3d2c512..0f65caf 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -6,7 +6,7 @@ import { Variant, Variants } from './variant'; * @category Core Usage */ export interface Client { - fetch(user?: ExperimentUser): Promise; + fetch(user?: ExperimentUser, options?: FetchOptions): Promise; variant(key: string, fallback?: string | Variant): Variant; all(): Variants; clear(): void; @@ -23,3 +23,13 @@ export interface Client { */ setUserProvider(userProvider: ExperimentUserProvider): Client; } + +/** + * Options to modify the behavior of a remote evaluation fetch request. + */ +export type FetchOptions = { + /** + * Specific flag keys to evaluate and set variants for. + */ + flagKeys?: string[]; +}; diff --git a/src/types/storage.ts b/src/types/storage.ts index 11c9d1c..5c164b6 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -4,6 +4,7 @@ export interface Storage { put(key: string, value: Variant): void; get(key: string): Variant; clear(): void; + remove(key: string): void; getAll(): Variants; save(): void; load(): void;