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 e08c950010..ba45d19f24 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,4 +1,5 @@ -import { IDependency } from '../../types'; +import { IDependency, IFeatureDependency } from '../../types'; +import { FeatureDependency } from './dependent-features'; export interface IDependentFeaturesReadModel { getChildren(parents: string[]): Promise; @@ -6,6 +7,7 @@ export interface IDependentFeaturesReadModel { // we're interested in the list of parents, not orphans getOrphanParents(parentsAndChildren: string[]): Promise; getParents(child: string): Promise; + getDependencies(children: string[]): Promise; getParentOptions(child: string): Promise; hasDependencies(feature: 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 fb03a64016..bc69095884 100644 --- a/src/lib/features/dependent-features/dependent-features-read-model.ts +++ b/src/lib/features/dependent-features/dependent-features-read-model.ts @@ -1,6 +1,7 @@ import { Db } from '../../db/db'; import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; -import { IDependency } from '../../types'; +import { IDependency, IFeatureDependency } from '../../types'; +import { FeatureDependency } from './dependent-features'; export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { private db: Db; @@ -43,6 +44,22 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel { })); } + async getDependencies(children: string[]): Promise { + const rows = await this.db('dependent_features').whereIn( + 'child', + children, + ); + + return rows.map((row) => ({ + feature: row.child, + dependency: { + feature: row.parent, + enabled: row.enabled, + variants: row.variants, + }, + })); + } + async getParentOptions(child: string): Promise { const result = await this.db('features') .where('features.name', child) 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 75657618bf..c9a85918f0 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,9 +1,13 @@ import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; -import { IDependency } from '../../types'; +import { IDependency, IFeatureDependency } from '../../types'; +import { FeatureDependency } from './dependent-features'; export class FakeDependentFeaturesReadModel implements IDependentFeaturesReadModel { + getDependencies(): Promise { + return Promise.resolve([]); + } getChildren(): Promise { return Promise.resolve([]); } diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts index b5ffe55df3..62e1ff4724 100644 --- a/src/lib/features/export-import-toggles/createExportImportService.ts +++ b/src/lib/features/export-import-toggles/createExportImportService.ts @@ -44,6 +44,8 @@ import { createPrivateProjectChecker, } from '../private-project/createPrivateProjectChecker'; import { DbServiceFactory } from 'lib/db/transaction'; +import { DependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model'; +import { FakeDependentFeaturesReadModel } from '../dependent-features/fake-dependent-features-read-model'; export const createFakeExportImportTogglesService = ( config: IUnleashConfig, @@ -102,6 +104,8 @@ export const createFakeExportImportTogglesService = ( { getLogger }, eventService, ); + const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); + const exportImportService = new ExportImportService( { importTogglesStore, @@ -123,6 +127,7 @@ export const createFakeExportImportTogglesService = ( strategyService, tagTypeService, }, + dependentFeaturesReadModel, ); return exportImportService; @@ -213,6 +218,8 @@ export const deferredExportImportTogglesService = ( { getLogger }, eventService, ); + const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); + const exportImportService = new ExportImportService( { importTogglesStore, @@ -234,6 +241,7 @@ export const deferredExportImportTogglesService = ( strategyService, tagTypeService, }, + dependentFeaturesReadModel, ); return exportImportService; diff --git a/src/lib/features/export-import-toggles/export-import-service.ts b/src/lib/features/export-import-toggles/export-import-service.ts index cd1d141da7..8b8435efe3 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -1,20 +1,21 @@ -import { IUnleashConfig } from '../../types/option'; +import { Logger } from '../../logger'; +import { IStrategy } from '../../types/stores/strategy-store'; +import { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type'; +import { IFeatureStrategiesStore } from '../feature-toggle/types/feature-toggle-strategies-store-type'; import { + IUnleashConfig, + IContextFieldStore, + IUnleashStores, + ISegmentStore, + IFeatureEnvironmentStore, + ITagTypeStore, + IFeatureTagStore, FeatureToggleDTO, IFeatureStrategy, IFeatureStrategySegment, IVariant, -} from '../../types/model'; -import { Logger } from '../../logger'; -import { IFeatureTagStore } from '../../types/stores/feature-tag-store'; -import { ITagTypeStore } from '../../types/stores/tag-type-store'; -import { IStrategy } from '../../types/stores/strategy-store'; -import { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type'; -import { IFeatureStrategiesStore } from '../feature-toggle/types/feature-toggle-strategies-store-type'; -import { IFeatureEnvironmentStore } from '../../types/stores/feature-environment-store'; -import { IContextFieldStore, IUnleashStores } from '../../types/stores'; -import { ISegmentStore } from '../../types/stores/segment-store'; -import { ExportQuerySchema } from '../../openapi/spec/export-query-schema'; +} from '../../types'; +import { ExportQuerySchema, ImportTogglesSchema } from '../../openapi'; import { FEATURES_EXPORTED, FEATURES_IMPORTED, @@ -27,7 +28,6 @@ import { FeatureStrategySchema, ImportTogglesValidateSchema, } from '../../openapi'; -import { ImportTogglesSchema } from '../../openapi/spec/import-toggles-schema'; import User from '../../types/user'; import { BadDataError } from '../../error'; import { extractUsernameFromUser } from '../../util'; @@ -49,6 +49,8 @@ import { ImportPermissionsService, Mode } from './import-permissions-service'; import { ImportValidationMessages } from './import-validation-messages'; import { findDuplicates } from '../../util/findDuplicates'; import { FeatureNameCheckResultWithFeaturePattern } from '../feature-toggle/feature-toggle-service'; +import { IDependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model-type'; +import groupBy from 'lodash.groupby'; export type IImportService = { validate( @@ -105,6 +107,8 @@ export default class ExportImportService private importPermissionsService: ImportPermissionsService; + private dependentFeaturesReadModel: IDependentFeaturesReadModel; + constructor( stores: Pick< IUnleashStores, @@ -139,6 +143,7 @@ export default class ExportImportService | 'tagTypeService' | 'featureTagService' >, + dependentFeaturesReadModel: IDependentFeaturesReadModel, ) { this.toggleStore = stores.featureToggleStore; this.importTogglesStore = stores.importTogglesStore; @@ -162,6 +167,7 @@ export default class ExportImportService this.tagTypeService, this.contextService, ); + this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.logger = getLogger('services/state-service.js'); } @@ -332,7 +338,10 @@ export default class ExportImportService if (tag.tagType) { await this.featureTagService.addTag( tag.featureName, - { type: tag.tagType, value: tag.tagValue }, + { + type: tag.tagType, + value: tag.tagValue, + }, extractUsernameFromUser(user), ); } @@ -657,6 +666,7 @@ export default class ExportImportService featureTags, segments, tagTypes, + featureDependencies, ] = await Promise.all([ this.toggleStore.getAllByNames(featureNames), await this.featureEnvironmentStore.getAllByFeatures( @@ -672,6 +682,7 @@ export default class ExportImportService this.featureTagStore.getAllByFeatures(featureNames), this.segmentStore.getAll(), this.tagTypeStore.getAll(), + this.dependentFeaturesReadModel.getDependencies(featureNames), ]); this.addSegmentsToStrategies(featureStrategies, strategySegments); const filteredContextFields = contextFields @@ -708,6 +719,19 @@ export default class ExportImportService const filteredTagTypes = tagTypes.filter((tagType) => featureTags.map((tag) => tag.tagType).includes(tagType.name), ); + + const groupedFeatureDependencies = groupBy( + featureDependencies, + 'feature', + ); + + const mappedFeatureDependencies = Object.entries( + groupedFeatureDependencies, + ).map(([feature, dependencies]) => ({ + feature, + dependencies: dependencies.map((d) => d.dependency), + })); + const result = { features: features.map((item) => { const { createdAt, archivedAt, lastSeenAt, ...rest } = item; @@ -741,9 +765,13 @@ export default class ExportImportService featureTags, segments: filteredSegments.map((item) => { const { id, name } = item; - return { id, name }; + return { + id, + name, + }; }), tagTypes: filteredTagTypes, + dependencies: mappedFeatureDependencies, }; await this.eventService.storeEvent({ type: FEATURES_EXPORTED, diff --git a/src/lib/features/export-import-toggles/export-import.e2e.test.ts b/src/lib/features/export-import-toggles/export-import.e2e.test.ts index a12164e2b1..335d4aedaa 100644 --- a/src/lib/features/export-import-toggles/export-import.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import.e2e.test.ts @@ -20,6 +20,7 @@ import { import { DEFAULT_ENV } from '../../util'; import { ContextFieldSchema, + CreateDependentFeatureSchema, ImportTogglesSchema, UpsertSegmentSchema, VariantsSchema, @@ -47,7 +48,10 @@ const defaultContext: ContextFieldSchema = { description: 'A region', legalValues: [ { value: 'north' }, - { value: 'south', description: 'south-desc' }, + { + value: 'south', + description: 'south-desc', + }, ], }; @@ -68,7 +72,11 @@ const createToggle = async ( if (strategy) { await app.services.featureToggleServiceV2.createStrategy( strategy, - { projectId, featureName: toggle.name, environment: DEFAULT_ENV }, + { + projectId, + featureName: toggle.name, + environment: DEFAULT_ENV, + }, username, ); } @@ -152,6 +160,7 @@ beforeAll(async () => { flags: { featuresExportImport: true, featureNamingPattern: true, + dependentFeatures: true, }, }, }, @@ -193,7 +202,10 @@ describe('import-export for project-specific segments', () => { }); const strategy = { name: 'default', - parameters: { rollout: '100', stickiness: 'default' }, + parameters: { + rollout: '100', + stickiness: 'default', + }, constraints: [ { contextName: 'appName', @@ -249,10 +261,16 @@ describe('import-export for project-specific segments', () => { test('exports features', async () => { const segmentName = 'my-segment'; await createProjects(); - const segment = await createSegment({ name: segmentName, constraints: [] }); + const segment = await createSegment({ + name: segmentName, + constraints: [], + }); const strategy = { name: 'default', - parameters: { rollout: '100', stickiness: 'default' }, + parameters: { + rollout: '100', + stickiness: 'default', + }, constraints: [ { contextName: 'appName', @@ -276,6 +294,8 @@ test('exports features', async () => { }, strategy, ); + + await app.addDependency(defaultFeatureName, 'second_feature'); const { body } = await app.request .post('/api/admin/features-batch/export') .send({ @@ -306,6 +326,17 @@ test('exports features', async () => { name: segmentName, }, ], + dependencies: [ + { + feature: defaultFeatureName, + dependencies: [ + { + feature: 'second_feature', + enabled: true, + }, + ], + }, + ], }); }); @@ -313,7 +344,10 @@ test('exports features by tag', async () => { await createProjects(); const strategy = { name: 'default', - parameters: { rollout: '100', stickiness: 'default' }, + parameters: { + rollout: '100', + stickiness: 'default', + }, constraints: [ { contextName: 'appName', @@ -386,7 +420,10 @@ test('should export custom context fields from strategies and variants', async ( await createContext(strategyStickinessContext); const strategy = { name: 'default', - parameters: { rollout: '100', stickiness: 'strategy-stickiness' }, + parameters: { + rollout: '100', + stickiness: 'strategy-stickiness', + }, constraints: [ { contextName: strategyContext.name, @@ -502,7 +539,12 @@ test('should export tags', async () => { featureName: defaultFeatureName, }, ], - featureTags: [{ featureName, tagValue: 'tag1' }], + featureTags: [ + { + featureName, + tagValue: 'tag1', + }, + ], }); }); @@ -598,15 +640,33 @@ const tags = [ ]; const resultTags = [ - { value: 'tag1', type: 'simple' }, - { value: 'tag2', type: 'simple' }, - { value: 'feature_tagged', type: 'special_tag' }, + { + value: 'tag1', + type: 'simple', + }, + { + value: 'tag2', + type: 'simple', + }, + { + value: 'feature_tagged', + type: 'special_tag', + }, ]; const tagTypes = [ - { name: 'bestt', description: 'test' }, - { name: 'special_tag', description: 'this is my special tag' }, - { name: 'special_tag', description: 'this is my special tag' }, // deliberate duplicate + { + name: 'bestt', + description: 'test', + }, + { + name: 'special_tag', + description: 'this is my special tag', + }, + { + name: 'special_tag', + description: 'this is my special tag', + }, // deliberate duplicate ]; const defaultImportPayload: ImportTogglesSchema = { @@ -724,11 +784,21 @@ test('import multiple features with same tag', async () => { expect(tags1).toMatchObject({ version: 1, - tags: [{ value: 'tag1', type: 'simple' }], + tags: [ + { + value: 'tag1', + type: 'simple', + }, + ], }); expect(tags2).toMatchObject({ version: 1, - tags: [{ value: 'tag1', type: 'simple' }], + tags: [ + { + value: 'tag1', + type: 'simple', + }, + ], }); }); @@ -746,7 +816,12 @@ test('can update toggles on subsequent import', async () => { ...defaultImportPayload, data: { ...defaultImportPayload.data, - features: [{ ...exportedFeature, type: 'operational' }], + features: [ + { + ...exportedFeature, + type: 'operational', + }, + ], }, }); @@ -782,7 +857,12 @@ test('reject import with unknown context fields', async () => { await createProjects(); const contextField = { name: 'ContextField1', - legalValues: [{ value: 'Value1', description: '' }], + legalValues: [ + { + value: 'Value1', + description: '', + }, + ], }; await app.createContextField(contextField); const importPayloadWithContextFields: ImportTogglesSchema = { @@ -792,7 +872,12 @@ test('reject import with unknown context fields', async () => { contextFields: [ { ...contextField, - legalValues: [{ value: 'Value2', description: '' }], + legalValues: [ + { + value: 'Value2', + description: '', + }, + ], }, ], }, @@ -813,7 +898,10 @@ test('reject import with unsupported strategies', async () => { data: { ...defaultImportPayload.data, featureStrategies: [ - { name: 'customStrategy', featureName: 'featureName' }, + { + name: 'customStrategy', + featureName: 'featureName', + }, ], }, }; @@ -874,7 +962,12 @@ test('validate import data', async () => { anotherExportedFeature, ], featureStrategies: [{ name: 'customStrategy' }], - segments: [{ id: 1, name: 'customSegment' }], + segments: [ + { + id: 1, + name: 'customSegment', + }, + ], contextFields: [ { ...contextField, diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 04b4b735bf..b9d3e640f9 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -182,6 +182,7 @@ import { contextFieldStrategiesSchema } from './spec/context-field-strategies-sc import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema'; import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema'; import { segmentStrategiesSchema } from './spec/admin-strategies-schema'; +import { featureDependenciesSchema } from './spec/feature-dependencies-schema'; // Schemas must have an $id property on the form "#/components/schemas/mySchema". export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; @@ -389,6 +390,7 @@ export const schemas: UnleashSchemas = { dependentFeatureSchema, createDependentFeatureSchema, parentFeatureOptionsSchema, + featureDependenciesSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/export-result-schema.ts b/src/lib/openapi/spec/export-result-schema.ts index 9404c638c7..377f1e6851 100644 --- a/src/lib/openapi/spec/export-result-schema.ts +++ b/src/lib/openapi/spec/export-result-schema.ts @@ -12,6 +12,8 @@ import { variantsSchema } from './variants-schema'; import { constraintSchema } from './constraint-schema'; import { tagTypeSchema } from './tag-type-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; +import { featureDependenciesSchema } from './feature-dependencies-schema'; +import { dependentFeatureSchema } from './dependent-feature-schema'; export const exportResultSchema = { $id: '#/components/schemas/exportResultSchema', @@ -166,6 +168,14 @@ export const exportResultSchema = { $ref: '#/components/schemas/tagTypeSchema', }, }, + dependencies: { + type: 'array', + description: + 'A list of all the dependencies for features in `features` list.', + items: { + $ref: '#/components/schemas/featureDependenciesSchema', + }, + }, }, components: { schemas: { @@ -182,6 +192,8 @@ export const exportResultSchema = { parametersSchema, legalValueSchema, tagTypeSchema, + featureDependenciesSchema, + dependentFeatureSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/feature-dependencies-schema.ts b/src/lib/openapi/spec/feature-dependencies-schema.ts new file mode 100644 index 0000000000..d6c7d3a242 --- /dev/null +++ b/src/lib/openapi/spec/feature-dependencies-schema.ts @@ -0,0 +1,34 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { dependentFeatureSchema } from './dependent-feature-schema'; + +export const featureDependenciesSchema = { + $id: '#/components/schemas/featureDependenciesSchema', + type: 'object', + description: + 'Feature dependency connection between a child feature and its dependencies', + required: ['feature', 'dependencies'], + additionalProperties: false, + properties: { + feature: { + type: 'string', + description: 'The name of the child feature.', + example: 'child_feature', + }, + dependencies: { + type: 'array', + description: 'List of parent features for the child feature', + items: { + $ref: '#/components/schemas/dependentFeatureSchema', + }, + }, + }, + components: { + schemas: { + dependentFeatureSchema, + }, + }, +} as const; + +export type FeatureDependenciesSchema = FromSchema< + typeof featureDependenciesSchema +>; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 364dfaf91d..735b69c803 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -146,6 +146,11 @@ export interface IDependency { enabled?: boolean; } +export interface IFeatureDependency { + feature: string; + dependency: IDependency; +} + export type IStrategyVariant = Omit; export interface IEnvironment {