diff --git a/src/lib/features/playground/offline-unleash-client.ts b/src/lib/features/playground/offline-unleash-client.ts index c1b580929f..3b11cbf5cc 100644 --- a/src/lib/features/playground/offline-unleash-client.ts +++ b/src/lib/features/playground/offline-unleash-client.ts @@ -14,7 +14,12 @@ type NonEmptyList = [T, ...T[]]; export const mapFeaturesForClient = ( features: FeatureConfigurationClient[], ): FeatureInterface[] => - features.map((feature) => ({ + features.map((feature) => mapFeatureForClient(feature)); + +export const mapFeatureForClient = ( + feature: FeatureConfigurationClient, +): FeatureInterface => { + return { impressionData: false, ...feature, variants: (feature.variants || []).map((variant) => ({ @@ -47,7 +52,8 @@ export const mapFeaturesForClient = ( })) || [], })), dependencies: feature.dependencies, - })); + }; +}; export const mapSegmentsForClient = (segments: ISegment[]): Segment[] => serializeDates(segments) as Segment[]; diff --git a/src/lib/proxy/client-feature-toggle-read-model-type.ts b/src/lib/proxy/client-feature-toggle-read-model-type.ts index d33a5cd84f..69776cda48 100644 --- a/src/lib/proxy/client-feature-toggle-read-model-type.ts +++ b/src/lib/proxy/client-feature-toggle-read-model-type.ts @@ -1,5 +1,5 @@ import { IFeatureToggleClient } from '../types'; export interface IClientFeatureToggleReadModel { - getClient(): Promise>; + getClient(): Promise>>; } diff --git a/src/lib/proxy/client-feature-toggle-read-model.ts b/src/lib/proxy/client-feature-toggle-read-model.ts index 202f3ef3b7..56ae47ea02 100644 --- a/src/lib/proxy/client-feature-toggle-read-model.ts +++ b/src/lib/proxy/client-feature-toggle-read-model.ts @@ -1,11 +1,5 @@ import { Knex } from 'knex'; -import { - IFeatureToggleClient, - IFeatureToggleQuery, - IStrategyConfig, - ITag, - PartialDeep, -} from '../types'; +import { IFeatureToggleClient, IStrategyConfig, PartialDeep } from '../types'; import { ensureStringValue, mapValues } from '../util'; import { Db } from '../db/db'; import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store'; @@ -15,12 +9,6 @@ import { DB_TIME } from '../metric-events'; import EventEmitter from 'events'; import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; -export interface IGetAllFeatures { - featureQuery?: IFeatureToggleQuery; - archived: boolean; - userId?: number; -} - export default class ClientFeatureToggleReadModel implements IClientFeatureToggleReadModel { @@ -37,7 +25,9 @@ export default class ClientFeatureToggleReadModel }); } - private async getAll(): Promise> { + private async getAll(): Promise< + Record> + > { const stopTimer = this.timer(`getAll`); const selectColumns = [ 'features.name as name', @@ -52,7 +42,6 @@ export default class ClientFeatureToggleReadModel 'fe.environment as environment', 'fs.id as strategy_id', 'fs.strategy_name as strategy_name', - 'fs.title as strategy_title', 'fs.disabled as strategy_disabled', 'fs.parameters as parameters', 'fs.constraints as constraints', @@ -102,23 +91,25 @@ export default class ClientFeatureToggleReadModel return data; } - getAggregatedData(rows): Record { - const featureTogglesByEnv: Record = {}; + getAggregatedData( + rows, + ): Record> { + const featureTogglesByEnv: Record< + string, + Record + > = {}; rows.forEach((row) => { const environment = row.environment; + const featureName = row.name; if (!featureTogglesByEnv[environment]) { - featureTogglesByEnv[environment] = []; + featureTogglesByEnv[environment] = {}; } - let feature = featureTogglesByEnv[environment].find( - (f) => f.name === row.name, - ); - - if (!feature) { - feature = { - name: row.name, + if (!featureTogglesByEnv[environment][featureName]) { + featureTogglesByEnv[environment][featureName] = { + name: featureName, strategies: [], variants: row.variants || [], impressionData: row.impression_data, @@ -127,13 +118,12 @@ export default class ClientFeatureToggleReadModel project: row.project, stale: row.stale, type: row.type, + dependencies: [], }; - featureTogglesByEnv[environment].push(feature); - } else { - if (this.isNewTag(feature, row)) { - this.addTag(feature, row); - } } + + const feature = featureTogglesByEnv[environment][featureName]; + if (row.parent) { feature.dependencies = feature.dependencies || []; feature.dependencies.push({ @@ -149,30 +139,20 @@ export default class ClientFeatureToggleReadModel this.isUnseenStrategyRow(feature, row) && !row.strategy_disabled ) { - feature.strategies?.push(this.rowToStrategy(row)); + feature.strategies = feature.strategies || []; + feature.strategies.push(this.rowToStrategy(row)); } }); - Object.keys(featureTogglesByEnv).forEach((envKey) => { - featureTogglesByEnv[envKey] = featureTogglesByEnv[envKey].map( - (featureToggle) => ({ - ...featureToggle, - strategies: featureToggle.strategies - ?.sort((strategy1, strategy2) => { - if ( - typeof strategy1.sortOrder === 'number' && - typeof strategy2.sortOrder === 'number' - ) { - return ( - strategy1.sortOrder - strategy2.sortOrder - ); - } - return 0; + Object.values(featureTogglesByEnv).forEach((envFeatures) => { + Object.values(envFeatures).forEach((feature) => { + if (feature.strategies) { + feature.strategies = feature.strategies + .sort((a, b) => { + return (a.sortOrder || 0) - (b.sortOrder || 0); }) - .map(({ id, title, sortOrder, ...strategy }) => ({ - ...strategy, - })), - }), - ); + .map(({ id, sortOrder, ...strategy }) => strategy); + } + }); }); return featureTogglesByEnv; @@ -191,13 +171,6 @@ export default class ClientFeatureToggleReadModel return strategy; } - private static rowToTag(row: Record): ITag { - return { - value: row.tag_value, - type: row.tag_type, - }; - } - private isUnseenStrategyRow( feature: PartialDeep, row: Record, @@ -208,30 +181,9 @@ export default class ClientFeatureToggleReadModel ); } - private addTag( - feature: Record, - row: Record, - ): void { - const tags = feature.tags || []; - const newTag = ClientFeatureToggleReadModel.rowToTag(row); - feature.tags = [...tags, newTag]; - } - - private isNewTag( - feature: PartialDeep, - row: Record, - ): boolean { - return ( - row.tag_type && - row.tag_value && - !feature.tags?.some( - (tag) => - tag?.type === row.tag_type && tag?.value === row.tag_value, - ) - ); - } - - async getClient(): Promise> { + async getClient(): Promise< + Record> + > { return this.getAll(); } } diff --git a/src/lib/proxy/fake-client-feature-toggle-read-model.ts b/src/lib/proxy/fake-client-feature-toggle-read-model.ts index e34833e046..89f7dc4a65 100644 --- a/src/lib/proxy/fake-client-feature-toggle-read-model.ts +++ b/src/lib/proxy/fake-client-feature-toggle-read-model.ts @@ -4,13 +4,18 @@ import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-mode export default class FakeClientFeatureToggleReadModel implements IClientFeatureToggleReadModel { - constructor(private value: Record = {}) {} + constructor( + private value: Record< + string, + Record + > = {}, + ) {} - getClient(): Promise> { + getClient(): Promise>> { return Promise.resolve(this.value); } - setValue(value: Record) { + setValue(value: Record>) { this.value = value; } } diff --git a/src/lib/proxy/frontend-api-repository.ts b/src/lib/proxy/frontend-api-repository.ts index d660a70c14..41591bd313 100644 --- a/src/lib/proxy/frontend-api-repository.ts +++ b/src/lib/proxy/frontend-api-repository.ts @@ -50,9 +50,7 @@ export class FrontendApiRepository getToggle(name: string): FeatureInterface { //@ts-ignore (we must update the node SDK to allow undefined) - return this.getToggles(this.token).find( - (feature) => feature.name === name, - ); + return this.globalFrontendApiCache.getToggle(name, this.token); } getToggles(): FeatureInterface[] { diff --git a/src/lib/proxy/global-frontend-api-cache.test.ts b/src/lib/proxy/global-frontend-api-cache.test.ts index 44c40b11b6..3b662d9d6e 100644 --- a/src/lib/proxy/global-frontend-api-cache.test.ts +++ b/src/lib/proxy/global-frontend-api-cache.test.ts @@ -46,7 +46,7 @@ const alwaysOnFlagResolver = { const createCache = ( segment: ISegment = defaultSegment, - features: Record = {}, + features: Record> = {}, ) => { const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver }; const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]); @@ -82,28 +82,28 @@ test('Can read initial segment', async () => { test('Can read initial features', async () => { const { cache } = createCache(defaultSegment, { - development: [ - { + development: { + featureA: { ...defaultFeature, name: 'featureA', enabled: true, project: 'projectA', }, - { + featureB: { ...defaultFeature, name: 'featureB', enabled: true, project: 'projectB', }, - ], - production: [ - { + }, + production: { + featureA: { ...defaultFeature, name: 'featureA', enabled: false, project: 'projectA', }, - ], + }, }); const featuresBeforeRead = cache.getToggles({ @@ -138,6 +138,18 @@ test('Can read initial features', async () => { projects: ['*'], } as IApiUser); expect(defaultProjectFeatures.length).toBe(0); + + const singleToggle = cache.getToggle('featureA', { + environment: 'development', + projects: ['*'], + } as IApiUser); + + expect(singleToggle).toMatchObject({ + ...defaultFeature, + name: 'featureA', + enabled: true, + impressionData: false, + }); }); test('Can refresh data on revision update', async () => { @@ -150,15 +162,15 @@ test('Can refresh data on revision update', async () => { await state(cache, 'ready'); clientFeatureToggleReadModel.setValue({ - development: [ - { + development: { + featureA: { ...defaultFeature, name: 'featureA', enabled: false, strategies: [{ name: 'default' }], project: 'projectA', }, - ], + }, }); configurationRevisionService.emit(UPDATE_REVISION); diff --git a/src/lib/proxy/global-frontend-api-cache.ts b/src/lib/proxy/global-frontend-api-cache.ts index a73e55e83c..9f6308c2e1 100644 --- a/src/lib/proxy/global-frontend-api-cache.ts +++ b/src/lib/proxy/global-frontend-api-cache.ts @@ -2,19 +2,24 @@ import EventEmitter from 'events'; import { Segment } from 'unleash-client/lib/strategy/strategy'; import { FeatureInterface } from 'unleash-client/lib/feature'; import { IApiUser } from '../types/api-user'; -import { ISegmentReadModel, IUnleashConfig } from '../types'; import { - mapFeaturesForClient, + IFeatureToggleClient, + ISegmentReadModel, + IUnleashConfig, +} from '../types'; +import { + mapFeatureForClient, mapSegmentsForClient, } from '../features/playground/offline-unleash-client'; import { ALL_ENVS } from '../util/constants'; import { Logger } from '../logger'; import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service'; -import { mapValues } from '../util'; import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; type Config = Pick; +type FrontendApiFeatureCache = Record>; + export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated'; export class GlobalFrontendApiCache extends EventEmitter { @@ -28,7 +33,7 @@ export class GlobalFrontendApiCache extends EventEmitter { private readonly configurationRevisionService: EventEmitter; - private featuresByEnvironment: Record = {}; + private featuresByEnvironment: FrontendApiFeatureCache = {}; private segments: Segment[] = []; @@ -58,30 +63,40 @@ export class GlobalFrontendApiCache extends EventEmitter { return this.segments.find((segment) => segment.id === id); } + getToggle(name: string, token: IApiUser): FeatureInterface { + const features = this.getTogglesByEnvironment( + this.environmentNameForToken(token), + ); + return features[name]; + } + getToggles(token: IApiUser): FeatureInterface[] { - if ( - this.featuresByEnvironment[this.environmentNameForToken(token)] == - null - ) - return []; - return this.featuresByEnvironment[ - this.environmentNameForToken(token) - ].filter( - (feature) => - token.projects.includes('*') || - (feature.project && token.projects.includes(feature.project)), + const features = this.getTogglesByEnvironment( + this.environmentNameForToken(token), + ); + return this.filterTogglesByProjects(features, token.projects); + } + + private filterTogglesByProjects( + features: Record, + projects: string[], + ): FeatureInterface[] { + if (projects.includes('*')) { + return Object.values(features); + } + return Object.values(features).filter( + (feature) => feature.project && projects.includes(feature.project), ); } - private async getAllFeatures(): Promise< - Record - > { - const features = await this.clientFeatureToggleReadModel.getClient(); - return mapValues(features, mapFeaturesForClient); - } + private getTogglesByEnvironment( + environment: string, + ): Record { + const features = this.featuresByEnvironment[environment]; - private async getAllSegments(): Promise { - return mapSegmentsForClient(await this.segmentReadModel.getAll()); + if (features == null) return {}; + + return features; } // TODO: fetch only relevant projects/environments based on tokens @@ -102,6 +117,15 @@ export class GlobalFrontendApiCache extends EventEmitter { } } + private async getAllFeatures(): Promise { + const features = await this.clientFeatureToggleReadModel.getClient(); + return this.mapFeatures(features); + } + + private async getAllSegments(): Promise { + return mapSegmentsForClient(await this.segmentReadModel.getAll()); + } + private async onUpdateRevisionEvent() { if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) { await this.refreshData(); @@ -114,4 +138,20 @@ export class GlobalFrontendApiCache extends EventEmitter { } return token.environment; } + + private mapFeatures( + features: Record>, + ): FrontendApiFeatureCache { + const entries = Object.entries(features).map(([key, value]) => [ + key, + Object.fromEntries( + Object.entries(value).map(([innerKey, innerValue]) => [ + innerKey, + mapFeatureForClient(innerValue), + ]), + ), + ]); + + return Object.fromEntries(entries); + } } diff --git a/src/lib/services/proxy-service.test.ts b/src/lib/services/proxy-service.test.ts index 88342b298a..922d454b5b 100644 --- a/src/lib/services/proxy-service.test.ts +++ b/src/lib/services/proxy-service.test.ts @@ -32,6 +32,11 @@ test('proxy service fetching features from global cache', async () => { }, ]; }, + getToggle(name: string, token: IApiUser): FeatureInterface { + return this.getToggles(token).find( + (t) => t.name === name, + ) as FeatureInterface; + }, } as GlobalFrontendApiCache; const proxyService = new ProxyService( { getLogger: noLogger } as unknown as Config,