diff --git a/src/lib/features/dependent-features/dependent-features-read-model-type.ts b/src/lib/features/dependent-features/dependent-features-read-model-type.ts index 171b8c8940..16b50077c4 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model-type.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model-type.ts @@ -1,5 +1,7 @@ +import { IDependency } from '../../types'; + export interface IDependentFeaturesReadModel { getChildren(parent: string): Promise; - getParents(child: string): Promise; + getParents(child: string): Promise; getParentOptions(child: string): Promise; } diff --git a/src/lib/features/dependent-features/dependent-features-read-model.ts b/src/lib/features/dependent-features/dependent-features-read-model.ts index a0a3659631..74eade15ca 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model.ts @@ -1,5 +1,6 @@ import { Db } from '../../db/db'; import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; +import { IDependency } from '../../types'; export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { private db: Db; @@ -17,10 +18,14 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { return rows.map((row) => row.child); } - async getParents(child: string): Promise { + async getParents(child: string): Promise { const rows = await this.db('dependent_features').where('child', child); - return rows.map((row) => row.parent); + return rows.map((row) => ({ + feature: row.parent, + enabled: row.enabled, + variants: row.variants, + })); } async getParentOptions(child: string): Promise { diff --git a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts index 86efe5fee9..80311c5893 100644 --- a/src/lib/features/dependent-features/fake-dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/fake-dependent-features-read-model.ts @@ -1,4 +1,5 @@ import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; +import { IDependency } from '../../types'; export class FakeDependentFeaturesReadModel implements IDependentFeaturesReadModel @@ -7,7 +8,7 @@ export class FakeDependentFeaturesReadModel return Promise.resolve([]); } - getParents(): Promise { + getParents(): Promise { return Promise.resolve([]); } diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 7ee45326f1..8ba3e96119 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -45,6 +45,8 @@ import { createFakePrivateProjectChecker, createPrivateProjectChecker, } from '../private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model'; +import { FakeDependentFeaturesReadModel } from '../dependent-features/fake-dependent-features-read-model'; export const createFeatureToggleService = ( db: Db, @@ -105,6 +107,8 @@ export const createFeatureToggleService = ( const privateProjectChecker = createPrivateProjectChecker(db, config); + const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -122,6 +126,7 @@ export const createFeatureToggleService = ( accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); return featureToggleService; }; @@ -155,7 +160,8 @@ export const createFakeFeatureToggleService = ( ); const segmentService = createFakeSegmentService(config); const changeRequestAccessReadModel = createFakeChangeRequestAccessService(); - const fakeprivateProjectChecker = createFakePrivateProjectChecker(); + const fakePrivateProjectChecker = createFakePrivateProjectChecker(); + const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -172,7 +178,8 @@ export const createFakeFeatureToggleService = ( segmentService, accessService, changeRequestAccessReadModel, - fakeprivateProjectChecker, + fakePrivateProjectChecker, + dependentFeaturesReadModel, ); return featureToggleService; }; diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index cc1d11ad61..dbdfbde0c5 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -121,6 +121,47 @@ export const featureSchema = { nullable: true, description: 'The list of feature tags', }, + children: { + type: 'array', + description: + 'The list of child feature names. This is an experimental field and may change.', + items: { + type: 'string', + example: 'some-feature', + }, + }, + dependencies: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['feature'], + properties: { + feature: { + description: 'The name of the parent feature', + type: 'string', + example: 'some-feature', + }, + enabled: { + description: + 'Whether the parent feature is enabled or not', + type: 'boolean', + example: true, + }, + variants: { + description: + 'The list of variants the parent feature should resolve to. Only valid when feature is enabled.', + type: 'array', + items: { + example: 'some-feature-blue-variant', + type: 'string', + }, + }, + }, + }, + description: + 'The list of parent dependencies. This is an experimental field and may change.', + }, }, components: { schemas: { diff --git a/src/lib/services/feature-service-potentially-stale.test.ts b/src/lib/services/feature-service-potentially-stale.test.ts index 126286c14f..8ae7848e5f 100644 --- a/src/lib/services/feature-service-potentially-stale.test.ts +++ b/src/lib/services/feature-service-potentially-stale.test.ts @@ -10,6 +10,7 @@ import { AccessService } from './access-service'; import { IChangeRequestAccessReadModel } from 'lib/features/change-request-access-service/change-request-access-read-model'; import { ISegmentService } from 'lib/segments/segment-service-interface'; import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType'; +import { IDependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model-type'; test('Should only store events for potentially stale on', async () => { expect.assertions(2); @@ -51,6 +52,7 @@ test('Should only store events for potentially stale on', async () => { {} as AccessService, {} as IChangeRequestAccessReadModel, {} as IPrivateProjectChecker, + {} as IDependentFeaturesReadModel, ); await featureToggleService.updatePotentiallyStaleFeatures(); diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 6bb6bd3a62..12a54bef96 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -16,9 +16,11 @@ import { FeatureToggle, FeatureToggleDTO, FeatureToggleLegacy, + FeatureToggleWithDependencies, FeatureToggleWithEnvironment, FeatureVariantEvent, IConstraint, + IDependency, IEventStore, IFeatureEnvironmentInfo, IFeatureEnvironmentStore, @@ -96,6 +98,7 @@ import { ISegmentService } from 'lib/segments/segment-service-interface'; import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model'; import { checkFeatureFlagNamesAgainstPattern } from '../features/feature-naming-pattern/feature-naming-validation'; import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType'; +import { IDependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model-type'; interface IFeatureContext { featureName: string; @@ -157,6 +160,8 @@ class FeatureToggleService { private privateProjectChecker: IPrivateProjectChecker; + private dependentFeaturesReadModel: IDependentFeaturesReadModel; + constructor( { featureStrategiesStore, @@ -188,6 +193,7 @@ class FeatureToggleService { accessService: AccessService, changeRequestAccessReadModel: IChangeRequestAccessReadModel, privateProjectChecker: IPrivateProjectChecker, + dependentFeaturesReadModel: IDependentFeaturesReadModel, ) { this.logger = getLogger('services/feature-toggle-service.ts'); this.featureStrategiesStore = featureStrategiesStore; @@ -204,6 +210,7 @@ class FeatureToggleService { this.flagResolver = flagResolver; this.changeRequestAccessReadModel = changeRequestAccessReadModel; this.privateProjectChecker = privateProjectChecker; + this.dependentFeaturesReadModel = dependentFeaturesReadModel; } async validateFeaturesContext( @@ -921,7 +928,7 @@ class FeatureToggleService { projectId, environmentVariants, userId, - }: IGetFeatureParams): Promise { + }: IGetFeatureParams): Promise { if (projectId) { await this.validateFeatureBelongsToProject({ featureName, @@ -929,18 +936,31 @@ class FeatureToggleService { }); } + let dependencies: IDependency[] = []; + let children: string[] = []; + if (this.flagResolver.isEnabled('dependentFeatures')) { + [dependencies, children] = await Promise.all([ + this.dependentFeaturesReadModel.getParents(featureName), + this.dependentFeaturesReadModel.getChildren(featureName), + ]); + } + if (environmentVariants) { - return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs( - featureName, - userId, - archived, - ); + const result = + await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs( + featureName, + userId, + archived, + ); + return { ...result, dependencies, children }; } else { - return this.featureStrategiesStore.getFeatureToggleWithEnvs( - featureName, - userId, - archived, - ); + const result = + await this.featureStrategiesStore.getFeatureToggleWithEnvs( + featureName, + userId, + archived, + ); + return { ...result, dependencies, children }; } } diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index af1320ff25..b14fba39a0 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -73,6 +73,8 @@ import { createDependentFeaturesService, createFakeDependentFeaturesService, } from '../features/dependent-features/createDependentFeaturesService'; +import { DependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model'; +import { FakeDependentFeaturesReadModel } from '../features/dependent-features/fake-dependent-features-read-model'; // TODO: will be moved to scheduler feature directory export const scheduleServices = async ( @@ -175,6 +177,9 @@ export const createServices = ( const privateProjectChecker = db ? createPrivateProjectChecker(db, config) : createFakePrivateProjectChecker(); + const dependentFeaturesReadModel = db + ? new DependentFeaturesReadModel(db) + : new FakeDependentFeaturesReadModel(); const contextService = new ContextService( stores, @@ -227,6 +232,7 @@ export const createServices = ( accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); const environmentService = new EnvironmentService(stores, config); const featureTagService = new FeatureTagService(stores, config); diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 9179697574..364dfaf91d 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -96,6 +96,12 @@ export interface FeatureToggleWithEnvironment extends FeatureToggle { environments: IEnvironmentDetail[]; } +export interface FeatureToggleWithDependencies + extends FeatureToggleWithEnvironment { + dependencies: IDependency[]; + children: string[]; +} + // @deprecated export interface FeatureToggleLegacy extends FeatureToggle { strategies: IStrategyConfig[]; diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index ac58a46e50..1f33738bf6 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -91,6 +91,7 @@ beforeAll(async () => { experimental: { flags: { strictSchemaValidation: true, + dependentFeatures: true, }, }, }, @@ -214,6 +215,32 @@ test('Can get project overview', async () => { }); }); +test('should list dependencies and children', async () => { + const parent = uuidv4(); + const child = uuidv4(); + await app.createFeature(parent, 'default'); + await app.createFeature(child, 'default'); + await app.addDependency(child, parent); + + const { body: childFeature } = await app.getProjectFeatures( + 'default', + child, + ); + const { body: parentFeature } = await app.getProjectFeatures( + 'default', + parent, + ); + + expect(childFeature).toMatchObject({ + children: [], + dependencies: [{ feature: parent, enabled: true, variants: [] }], + }); + expect(parentFeature).toMatchObject({ + children: [child], + dependencies: [], + }); +}); + test('Can get features for project', async () => { await app.request .post('/api/admin/projects/default/features') diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 4e32d5f394..2eac2f01f9 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -20,6 +20,7 @@ beforeAll(async () => { flags: { strictSchemaValidation: true, featureNamingPattern: true, + dependentFeatures: true, }, }, }, diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 2941901bc8..6ef7896881 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -63,6 +63,8 @@ export interface IUnleashHttpAPI { importPayload: ImportTogglesSchema, expectedResponseCode?: number, ): supertest.Test; + + addDependency(child: string, parent: string): supertest.Test; } function httpApis( @@ -161,6 +163,21 @@ function httpApis( .set('Content-Type', 'application/json') .expect(expectedResponseCode); }, + + addDependency( + child: string, + parent: string, + project = DEFAULT_PROJECT, + expectedResponseCode: number = 200, + ): supertest.Test { + return request + .post( + `/api/admin/projects/${project}/features/${child}/dependencies`, + ) + .send({ feature: parent }) + .set('Content-Type', 'application/json') + .expect(expectedResponseCode); + }, }; } diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts index 1557a975a2..c21536fb0d 100644 --- a/src/test/e2e/services/access-service.e2e.test.ts +++ b/src/test/e2e/services/access-service.e2e.test.ts @@ -23,6 +23,7 @@ import { GroupService } from '../../../lib/services/group-service'; import { FavoritesService } from '../../../lib/services'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let db: ITestDb; let stores: IUnleashStores; @@ -250,6 +251,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); featureToggleService = new FeatureToggleService( stores, config, @@ -262,6 +266,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); favoritesService = new FavoritesService(stores, config); projectService = new ProjectService( diff --git a/src/test/e2e/services/api-token-service.e2e.test.ts b/src/test/e2e/services/api-token-service.e2e.test.ts index 89ec5bd8ed..80006029fb 100644 --- a/src/test/e2e/services/api-token-service.e2e.test.ts +++ b/src/test/e2e/services/api-token-service.e2e.test.ts @@ -13,6 +13,7 @@ import { GroupService } from '../../../lib/services/group-service'; import { FavoritesService } from '../../../lib/services'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let db; let stores; @@ -36,6 +37,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); const featureToggleService = new FeatureToggleService( stores, config, @@ -48,6 +52,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); const project = { id: 'test-project', diff --git a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts index 7947d643f4..f7d026d2cc 100644 --- a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts +++ b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts @@ -24,6 +24,7 @@ import { import { ISegmentService } from '../../../lib/segments/segment-service-interface'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let stores: IUnleashStores; let db; @@ -63,6 +64,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); segmentService = new SegmentService( stores, changeRequestAccessReadModel, @@ -77,6 +81,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); }); @@ -466,6 +471,9 @@ test('If change requests are enabled, cannot change variants without going via C db.rawDatabase, unleashConfig, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); // Force all feature flags on to make sure we have Change requests on const customFeatureService = new FeatureToggleService( stores, @@ -479,6 +487,7 @@ test('If change requests are enabled, cannot change variants without going via C accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); const newVariant: IVariant = { @@ -554,6 +563,9 @@ test('If CRs are protected for any environment in the project stops bulk update db.rawDatabase, unleashConfig, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); // Force all feature flags on to make sure we have Change requests on const customFeatureService = new FeatureToggleService( stores, @@ -567,6 +579,7 @@ test('If CRs are protected for any environment in the project stops bulk update accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); const toggle = await service.createFeatureToggle( diff --git a/src/test/e2e/services/playground-service.test.ts b/src/test/e2e/services/playground-service.test.ts index ecd653241b..5d311a7e3d 100644 --- a/src/test/e2e/services/playground-service.test.ts +++ b/src/test/e2e/services/playground-service.test.ts @@ -26,6 +26,7 @@ import { AccessService } from '../../../lib/services/access-service'; import { ISegmentService } from '../../../lib/segments/segment-service-interface'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let stores: IUnleashStores; let db: ITestDb; @@ -47,6 +48,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); segmentService = new SegmentService( stores, changeRequestAccessReadModel, @@ -61,6 +65,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); service = new PlaygroundService(config, { featureToggleServiceV2: featureToggleService, diff --git a/src/test/e2e/services/project-health-service.e2e.test.ts b/src/test/e2e/services/project-health-service.e2e.test.ts index c2969c2865..3d828addd5 100644 --- a/src/test/e2e/services/project-health-service.e2e.test.ts +++ b/src/test/e2e/services/project-health-service.e2e.test.ts @@ -12,6 +12,7 @@ import { GroupService } from '../../../lib/services/group-service'; import { FavoritesService } from '../../../lib/services'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let stores: IUnleashStores; let db: ITestDb; @@ -41,6 +42,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); featureToggleService = new FeatureToggleService( stores, config, @@ -53,6 +57,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); favoritesService = new FavoritesService(stores, config); diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 08b8bded34..f8ad773401 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -16,6 +16,7 @@ import { FeatureEnvironmentEvent } from '../../../lib/types/events'; import { addDays, subDays } from 'date-fns'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; +import { DependentFeaturesReadModel } from '../../../lib/features/dependent-features/dependent-features-read-model'; let stores; let db: ITestDb; @@ -62,6 +63,9 @@ beforeAll(async () => { db.rawDatabase, config, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel( + db.rawDatabase, + ); featureToggleService = new FeatureToggleService( stores, config, @@ -74,6 +78,7 @@ beforeAll(async () => { accessService, changeRequestAccessReadModel, privateProjectChecker, + dependentFeaturesReadModel, ); favoritesService = new FavoritesService(stores, config);