From 85c7f84f8d3f0f7055a3150f4eefea604326813c Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 20 Sep 2023 11:53:43 +0200 Subject: [PATCH] feat: Client api dependent features (#4778) --- src/lib/db/feature-toggle-client-store.ts | 18 ++++++++- .../dependent-features-service.ts | 10 ++--- src/lib/openapi/spec/client-feature-schema.ts | 9 +++++ .../openapi/spec/client-features-schema.ts | 2 + src/lib/services/feature-toggle-service.ts | 2 + src/lib/types/model.ts | 7 ++++ .../types/stores/feature-strategies-store.ts | 2 + src/test/e2e/api/client/feature.e2e.test.ts | 39 ++++++++++++++++++- 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index 1e4b8845ca..77cfad5a7c 100644 --- a/src/lib/db/feature-toggle-client-store.ts +++ b/src/lib/db/feature-toggle-client-store.ts @@ -67,6 +67,8 @@ export default class FeatureToggleClientStore const isPlayground = requestType === 'playground'; const environment = featureQuery?.environment || DEFAULT_ENV; const stopTimer = this.timer('getFeatureAdmin'); + const dependentFeaturesEnabled = + this.flagResolver.isEnabled('dependentFeatures'); let selectColumns = [ 'features.name as name', @@ -91,6 +93,9 @@ export default class FeatureToggleClientStore 'fs.variants as strategy_variants', 'segments.id as segment_id', 'segments.constraints as segment_constraints', + 'df.parent as parent', + 'df.variants as parent_variants', + 'df.enabled as parent_enabled', ] as (string | Raw)[]; let query = this.db('features') @@ -122,7 +127,8 @@ export default class FeatureToggleClientStore `fss.feature_strategy_id`, `fs.id`, ) - .leftJoin('segments', `segments.id`, `fss.segment_id`); + .leftJoin('segments', `segments.id`, `fss.segment_id`) + .leftJoin('dependent_features as df', 'df.child', 'features.name'); if (isAdmin) { query = query.leftJoin( @@ -195,6 +201,16 @@ export default class FeatureToggleClientStore ) { this.addSegmentIdsToStrategy(feature, r); } + if (r.parent && !isAdmin && dependentFeaturesEnabled) { + feature.dependencies = feature.dependencies || []; + feature.dependencies.push({ + feature: r.parent, + enabled: r.parent_enabled, + ...(r.parent_enabled + ? { variants: r.parent_variants } + : {}), + }); + } feature.impressionData = r.impression_data; feature.enabled = !!r.enabled; feature.name = r.name; diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index a31fb37445..d437a108fe 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -17,20 +17,20 @@ export class DependentFeaturesService { } async upsertFeatureDependency( - parentFeature: string, + childFeature: string, dependentFeature: CreateDependentFeatureSchema, ): Promise { const { enabled, feature, variants } = dependentFeature; const featureDependency: FeatureDependency = enabled === false ? { - parent: parentFeature, - child: feature, + parent: feature, + child: childFeature, enabled, } : { - parent: parentFeature, - child: feature, + parent: feature, + child: childFeature, enabled: true, variants, }; diff --git a/src/lib/openapi/spec/client-feature-schema.ts b/src/lib/openapi/spec/client-feature-schema.ts index 106586c35c..de370adda3 100644 --- a/src/lib/openapi/spec/client-feature-schema.ts +++ b/src/lib/openapi/spec/client-feature-schema.ts @@ -5,6 +5,7 @@ import { featureStrategySchema } from './feature-strategy-schema'; import { variantSchema } from './variant-schema'; import { overrideSchema } from './override-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; +import { dependentFeatureSchema } from './dependent-feature-schema'; export const clientFeatureSchema = { $id: '#/components/schemas/clientFeatureSchema', @@ -73,6 +74,13 @@ export const clientFeatureSchema = { }, nullable: true, }, + dependencies: { + type: 'array', + description: 'Feature dependencies for this toggle', + items: { + $ref: '#/components/schemas/dependentFeatureSchema', + }, + }, }, components: { schemas: { @@ -82,6 +90,7 @@ export const clientFeatureSchema = { strategyVariantSchema, variantSchema, overrideSchema, + dependentFeatureSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/client-features-schema.ts b/src/lib/openapi/spec/client-features-schema.ts index 66ffda0d57..5e1aa924b2 100644 --- a/src/lib/openapi/spec/client-features-schema.ts +++ b/src/lib/openapi/spec/client-features-schema.ts @@ -9,6 +9,7 @@ import { featureStrategySchema } from './feature-strategy-schema'; import { clientFeatureSchema } from './client-feature-schema'; import { variantSchema } from './variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; +import { dependentFeatureSchema } from './dependent-feature-schema'; export const clientFeaturesSchema = { $id: '#/components/schemas/clientFeaturesSchema', @@ -57,6 +58,7 @@ export const clientFeaturesSchema = { featureStrategySchema, strategyVariantSchema, variantSchema, + dependentFeatureSchema, }, }, } as const; diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 635ff40ffe..17b35c2596 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -986,6 +986,7 @@ class FeatureToggleService { variants, description, impressionData, + dependencies, }) => ({ name, type, @@ -996,6 +997,7 @@ class FeatureToggleService { variants, description, impressionData, + dependencies, }), ); } diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index ed0d68636b..8918376557 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -76,6 +76,7 @@ export interface IFeatureToggleClient { variants: IVariant[]; enabled: boolean; strategies: Omit[]; + dependencies?: IDependency[]; impressionData?: boolean; lastSeenAt?: Date; createdAt?: Date; @@ -133,6 +134,12 @@ export interface IVariant { }[]; } +export interface IDependency { + feature: string; + variants?: string[]; + enabled?: boolean; +} + export type IStrategyVariant = Omit; export interface IEnvironment { diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 7e76500247..ac84251be5 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -1,5 +1,6 @@ import { FeatureToggleWithEnvironment, + IDependency, IFeatureOverview, IFeatureStrategy, IStrategyConfig, @@ -16,6 +17,7 @@ export interface FeatureConfigurationClient { stale: boolean; strategies: IStrategyConfig[]; variants: IVariant[]; + dependencies?: IDependency[]; } export interface IFeatureStrategiesStore extends Store { diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 059d1c5881..58912e8664 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -10,7 +10,9 @@ let app: IUnleashTest; let db: ITestDb; beforeAll(async () => { - db = await dbInit('feature_api_client', getLogger); + db = await dbInit('feature_api_client', getLogger, { + experimental: { flags: { dependentFeatures: true } }, + }); app = await setupAppWithCustomConfig(db.stores, { experimental: { flags: { @@ -36,6 +38,7 @@ beforeAll(async () => { }, 'test', ); + await app.services.featureToggleServiceV2.createFeatureToggle( 'default', { @@ -52,6 +55,16 @@ beforeAll(async () => { }, 'test', ); + // depend on enabled feature with variant + await app.services.dependentFeaturesService.upsertFeatureDependency( + 'featureY', + { feature: 'featureX', variants: ['featureXVariant'] }, + ); + // depend on parent being disabled + await app.services.dependentFeaturesService.upsertFeatureDependency( + 'featureY', + { feature: 'featureZ', enabled: false }, + ); await app.services.featureToggleServiceV2.archiveToggle( 'featureArchivedX', @@ -127,6 +140,30 @@ test('returns four feature toggles', async () => { }); }); +test('returns dependencies', async () => { + return app.request + .get('/api/client/features') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.features[0]).toMatchObject({ + name: 'featureY', + dependencies: [ + { + feature: 'featureX', + enabled: true, + variants: ['featureXVariant'], + }, + { + feature: 'featureZ', + enabled: false, + }, + ], + }); + expect(res.body.features[1].dependencies).toBe(undefined); + }); +}); + test('returns four feature toggles without createdAt', async () => { return app.request .get('/api/client/features')