1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-19 01:17:18 +02:00

feat: export dependent feature toggles (#5007)

This commit is contained in:
Jaanus Sellin 2023-10-12 12:56:10 +03:00 committed by GitHub
parent 7343183f2d
commit 2059706e77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 244 additions and 39 deletions

View File

@ -1,4 +1,5 @@
import { IDependency } from '../../types'; import { IDependency, IFeatureDependency } from '../../types';
import { FeatureDependency } from './dependent-features';
export interface IDependentFeaturesReadModel { export interface IDependentFeaturesReadModel {
getChildren(parents: string[]): Promise<string[]>; getChildren(parents: string[]): Promise<string[]>;
@ -6,6 +7,7 @@ export interface IDependentFeaturesReadModel {
// we're interested in the list of parents, not orphans // we're interested in the list of parents, not orphans
getOrphanParents(parentsAndChildren: string[]): Promise<string[]>; getOrphanParents(parentsAndChildren: string[]): Promise<string[]>;
getParents(child: string): Promise<IDependency[]>; getParents(child: string): Promise<IDependency[]>;
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
getParentOptions(child: string): Promise<string[]>; getParentOptions(child: string): Promise<string[]>;
hasDependencies(feature: string): Promise<boolean>; hasDependencies(feature: string): Promise<boolean>;
} }

View File

@ -1,6 +1,7 @@
import { Db } from '../../db/db'; import { Db } from '../../db/db';
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; 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 { export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
private db: Db; private db: Db;
@ -43,6 +44,22 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
})); }));
} }
async getDependencies(children: string[]): Promise<IFeatureDependency[]> {
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<string[]> { async getParentOptions(child: string): Promise<string[]> {
const result = await this.db('features') const result = await this.db('features')
.where('features.name', child) .where('features.name', child)

View File

@ -1,9 +1,13 @@
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; 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 export class FakeDependentFeaturesReadModel
implements IDependentFeaturesReadModel implements IDependentFeaturesReadModel
{ {
getDependencies(): Promise<IFeatureDependency[]> {
return Promise.resolve([]);
}
getChildren(): Promise<string[]> { getChildren(): Promise<string[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }

View File

@ -44,6 +44,8 @@ import {
createPrivateProjectChecker, createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker'; } from '../private-project/createPrivateProjectChecker';
import { DbServiceFactory } from 'lib/db/transaction'; 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 = ( export const createFakeExportImportTogglesService = (
config: IUnleashConfig, config: IUnleashConfig,
@ -102,6 +104,8 @@ export const createFakeExportImportTogglesService = (
{ getLogger }, { getLogger },
eventService, eventService,
); );
const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel();
const exportImportService = new ExportImportService( const exportImportService = new ExportImportService(
{ {
importTogglesStore, importTogglesStore,
@ -123,6 +127,7 @@ export const createFakeExportImportTogglesService = (
strategyService, strategyService,
tagTypeService, tagTypeService,
}, },
dependentFeaturesReadModel,
); );
return exportImportService; return exportImportService;
@ -213,6 +218,8 @@ export const deferredExportImportTogglesService = (
{ getLogger }, { getLogger },
eventService, eventService,
); );
const dependentFeaturesReadModel = new DependentFeaturesReadModel(db);
const exportImportService = new ExportImportService( const exportImportService = new ExportImportService(
{ {
importTogglesStore, importTogglesStore,
@ -234,6 +241,7 @@ export const deferredExportImportTogglesService = (
strategyService, strategyService,
tagTypeService, tagTypeService,
}, },
dependentFeaturesReadModel,
); );
return exportImportService; return exportImportService;

View File

@ -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 { import {
IUnleashConfig,
IContextFieldStore,
IUnleashStores,
ISegmentStore,
IFeatureEnvironmentStore,
ITagTypeStore,
IFeatureTagStore,
FeatureToggleDTO, FeatureToggleDTO,
IFeatureStrategy, IFeatureStrategy,
IFeatureStrategySegment, IFeatureStrategySegment,
IVariant, IVariant,
} from '../../types/model'; } from '../../types';
import { Logger } from '../../logger'; import { ExportQuerySchema, ImportTogglesSchema } from '../../openapi';
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';
import { import {
FEATURES_EXPORTED, FEATURES_EXPORTED,
FEATURES_IMPORTED, FEATURES_IMPORTED,
@ -27,7 +28,6 @@ import {
FeatureStrategySchema, FeatureStrategySchema,
ImportTogglesValidateSchema, ImportTogglesValidateSchema,
} from '../../openapi'; } from '../../openapi';
import { ImportTogglesSchema } from '../../openapi/spec/import-toggles-schema';
import User from '../../types/user'; import User from '../../types/user';
import { BadDataError } from '../../error'; import { BadDataError } from '../../error';
import { extractUsernameFromUser } from '../../util'; import { extractUsernameFromUser } from '../../util';
@ -49,6 +49,8 @@ import { ImportPermissionsService, Mode } from './import-permissions-service';
import { ImportValidationMessages } from './import-validation-messages'; import { ImportValidationMessages } from './import-validation-messages';
import { findDuplicates } from '../../util/findDuplicates'; import { findDuplicates } from '../../util/findDuplicates';
import { FeatureNameCheckResultWithFeaturePattern } from '../feature-toggle/feature-toggle-service'; 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 = { export type IImportService = {
validate( validate(
@ -105,6 +107,8 @@ export default class ExportImportService
private importPermissionsService: ImportPermissionsService; private importPermissionsService: ImportPermissionsService;
private dependentFeaturesReadModel: IDependentFeaturesReadModel;
constructor( constructor(
stores: Pick< stores: Pick<
IUnleashStores, IUnleashStores,
@ -139,6 +143,7 @@ export default class ExportImportService
| 'tagTypeService' | 'tagTypeService'
| 'featureTagService' | 'featureTagService'
>, >,
dependentFeaturesReadModel: IDependentFeaturesReadModel,
) { ) {
this.toggleStore = stores.featureToggleStore; this.toggleStore = stores.featureToggleStore;
this.importTogglesStore = stores.importTogglesStore; this.importTogglesStore = stores.importTogglesStore;
@ -162,6 +167,7 @@ export default class ExportImportService
this.tagTypeService, this.tagTypeService,
this.contextService, this.contextService,
); );
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.logger = getLogger('services/state-service.js'); this.logger = getLogger('services/state-service.js');
} }
@ -332,7 +338,10 @@ export default class ExportImportService
if (tag.tagType) { if (tag.tagType) {
await this.featureTagService.addTag( await this.featureTagService.addTag(
tag.featureName, tag.featureName,
{ type: tag.tagType, value: tag.tagValue }, {
type: tag.tagType,
value: tag.tagValue,
},
extractUsernameFromUser(user), extractUsernameFromUser(user),
); );
} }
@ -657,6 +666,7 @@ export default class ExportImportService
featureTags, featureTags,
segments, segments,
tagTypes, tagTypes,
featureDependencies,
] = await Promise.all([ ] = await Promise.all([
this.toggleStore.getAllByNames(featureNames), this.toggleStore.getAllByNames(featureNames),
await this.featureEnvironmentStore.getAllByFeatures( await this.featureEnvironmentStore.getAllByFeatures(
@ -672,6 +682,7 @@ export default class ExportImportService
this.featureTagStore.getAllByFeatures(featureNames), this.featureTagStore.getAllByFeatures(featureNames),
this.segmentStore.getAll(), this.segmentStore.getAll(),
this.tagTypeStore.getAll(), this.tagTypeStore.getAll(),
this.dependentFeaturesReadModel.getDependencies(featureNames),
]); ]);
this.addSegmentsToStrategies(featureStrategies, strategySegments); this.addSegmentsToStrategies(featureStrategies, strategySegments);
const filteredContextFields = contextFields const filteredContextFields = contextFields
@ -708,6 +719,19 @@ export default class ExportImportService
const filteredTagTypes = tagTypes.filter((tagType) => const filteredTagTypes = tagTypes.filter((tagType) =>
featureTags.map((tag) => tag.tagType).includes(tagType.name), 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 = { const result = {
features: features.map((item) => { features: features.map((item) => {
const { createdAt, archivedAt, lastSeenAt, ...rest } = item; const { createdAt, archivedAt, lastSeenAt, ...rest } = item;
@ -741,9 +765,13 @@ export default class ExportImportService
featureTags, featureTags,
segments: filteredSegments.map((item) => { segments: filteredSegments.map((item) => {
const { id, name } = item; const { id, name } = item;
return { id, name }; return {
id,
name,
};
}), }),
tagTypes: filteredTagTypes, tagTypes: filteredTagTypes,
dependencies: mappedFeatureDependencies,
}; };
await this.eventService.storeEvent({ await this.eventService.storeEvent({
type: FEATURES_EXPORTED, type: FEATURES_EXPORTED,

View File

@ -20,6 +20,7 @@ import {
import { DEFAULT_ENV } from '../../util'; import { DEFAULT_ENV } from '../../util';
import { import {
ContextFieldSchema, ContextFieldSchema,
CreateDependentFeatureSchema,
ImportTogglesSchema, ImportTogglesSchema,
UpsertSegmentSchema, UpsertSegmentSchema,
VariantsSchema, VariantsSchema,
@ -47,7 +48,10 @@ const defaultContext: ContextFieldSchema = {
description: 'A region', description: 'A region',
legalValues: [ legalValues: [
{ value: 'north' }, { value: 'north' },
{ value: 'south', description: 'south-desc' }, {
value: 'south',
description: 'south-desc',
},
], ],
}; };
@ -68,7 +72,11 @@ const createToggle = async (
if (strategy) { if (strategy) {
await app.services.featureToggleServiceV2.createStrategy( await app.services.featureToggleServiceV2.createStrategy(
strategy, strategy,
{ projectId, featureName: toggle.name, environment: DEFAULT_ENV }, {
projectId,
featureName: toggle.name,
environment: DEFAULT_ENV,
},
username, username,
); );
} }
@ -152,6 +160,7 @@ beforeAll(async () => {
flags: { flags: {
featuresExportImport: true, featuresExportImport: true,
featureNamingPattern: true, featureNamingPattern: true,
dependentFeatures: true,
}, },
}, },
}, },
@ -193,7 +202,10 @@ describe('import-export for project-specific segments', () => {
}); });
const strategy = { const strategy = {
name: 'default', name: 'default',
parameters: { rollout: '100', stickiness: 'default' }, parameters: {
rollout: '100',
stickiness: 'default',
},
constraints: [ constraints: [
{ {
contextName: 'appName', contextName: 'appName',
@ -249,10 +261,16 @@ describe('import-export for project-specific segments', () => {
test('exports features', async () => { test('exports features', async () => {
const segmentName = 'my-segment'; const segmentName = 'my-segment';
await createProjects(); await createProjects();
const segment = await createSegment({ name: segmentName, constraints: [] }); const segment = await createSegment({
name: segmentName,
constraints: [],
});
const strategy = { const strategy = {
name: 'default', name: 'default',
parameters: { rollout: '100', stickiness: 'default' }, parameters: {
rollout: '100',
stickiness: 'default',
},
constraints: [ constraints: [
{ {
contextName: 'appName', contextName: 'appName',
@ -276,6 +294,8 @@ test('exports features', async () => {
}, },
strategy, strategy,
); );
await app.addDependency(defaultFeatureName, 'second_feature');
const { body } = await app.request const { body } = await app.request
.post('/api/admin/features-batch/export') .post('/api/admin/features-batch/export')
.send({ .send({
@ -306,6 +326,17 @@ test('exports features', async () => {
name: segmentName, name: segmentName,
}, },
], ],
dependencies: [
{
feature: defaultFeatureName,
dependencies: [
{
feature: 'second_feature',
enabled: true,
},
],
},
],
}); });
}); });
@ -313,7 +344,10 @@ test('exports features by tag', async () => {
await createProjects(); await createProjects();
const strategy = { const strategy = {
name: 'default', name: 'default',
parameters: { rollout: '100', stickiness: 'default' }, parameters: {
rollout: '100',
stickiness: 'default',
},
constraints: [ constraints: [
{ {
contextName: 'appName', contextName: 'appName',
@ -386,7 +420,10 @@ test('should export custom context fields from strategies and variants', async (
await createContext(strategyStickinessContext); await createContext(strategyStickinessContext);
const strategy = { const strategy = {
name: 'default', name: 'default',
parameters: { rollout: '100', stickiness: 'strategy-stickiness' }, parameters: {
rollout: '100',
stickiness: 'strategy-stickiness',
},
constraints: [ constraints: [
{ {
contextName: strategyContext.name, contextName: strategyContext.name,
@ -502,7 +539,12 @@ test('should export tags', async () => {
featureName: defaultFeatureName, featureName: defaultFeatureName,
}, },
], ],
featureTags: [{ featureName, tagValue: 'tag1' }], featureTags: [
{
featureName,
tagValue: 'tag1',
},
],
}); });
}); });
@ -598,15 +640,33 @@ const tags = [
]; ];
const resultTags = [ const resultTags = [
{ value: 'tag1', type: 'simple' }, {
{ value: 'tag2', type: 'simple' }, value: 'tag1',
{ value: 'feature_tagged', type: 'special_tag' }, type: 'simple',
},
{
value: 'tag2',
type: 'simple',
},
{
value: 'feature_tagged',
type: 'special_tag',
},
]; ];
const tagTypes = [ const tagTypes = [
{ name: 'bestt', description: 'test' }, {
{ name: 'special_tag', description: 'this is my special tag' }, name: 'bestt',
{ name: 'special_tag', description: 'this is my special tag' }, // deliberate duplicate 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 = { const defaultImportPayload: ImportTogglesSchema = {
@ -724,11 +784,21 @@ test('import multiple features with same tag', async () => {
expect(tags1).toMatchObject({ expect(tags1).toMatchObject({
version: 1, version: 1,
tags: [{ value: 'tag1', type: 'simple' }], tags: [
{
value: 'tag1',
type: 'simple',
},
],
}); });
expect(tags2).toMatchObject({ expect(tags2).toMatchObject({
version: 1, 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, ...defaultImportPayload,
data: { data: {
...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(); await createProjects();
const contextField = { const contextField = {
name: 'ContextField1', name: 'ContextField1',
legalValues: [{ value: 'Value1', description: '' }], legalValues: [
{
value: 'Value1',
description: '',
},
],
}; };
await app.createContextField(contextField); await app.createContextField(contextField);
const importPayloadWithContextFields: ImportTogglesSchema = { const importPayloadWithContextFields: ImportTogglesSchema = {
@ -792,7 +872,12 @@ test('reject import with unknown context fields', async () => {
contextFields: [ contextFields: [
{ {
...contextField, ...contextField,
legalValues: [{ value: 'Value2', description: '' }], legalValues: [
{
value: 'Value2',
description: '',
},
],
}, },
], ],
}, },
@ -813,7 +898,10 @@ test('reject import with unsupported strategies', async () => {
data: { data: {
...defaultImportPayload.data, ...defaultImportPayload.data,
featureStrategies: [ featureStrategies: [
{ name: 'customStrategy', featureName: 'featureName' }, {
name: 'customStrategy',
featureName: 'featureName',
},
], ],
}, },
}; };
@ -874,7 +962,12 @@ test('validate import data', async () => {
anotherExportedFeature, anotherExportedFeature,
], ],
featureStrategies: [{ name: 'customStrategy' }], featureStrategies: [{ name: 'customStrategy' }],
segments: [{ id: 1, name: 'customSegment' }], segments: [
{
id: 1,
name: 'customSegment',
},
],
contextFields: [ contextFields: [
{ {
...contextField, ...contextField,

View File

@ -182,6 +182,7 @@ import { contextFieldStrategiesSchema } from './spec/context-field-strategies-sc
import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema'; import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema';
import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema'; import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema';
import { segmentStrategiesSchema } from './spec/admin-strategies-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". // Schemas must have an $id property on the form "#/components/schemas/mySchema".
export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
@ -389,6 +390,7 @@ export const schemas: UnleashSchemas = {
dependentFeatureSchema, dependentFeatureSchema,
createDependentFeatureSchema, createDependentFeatureSchema,
parentFeatureOptionsSchema, parentFeatureOptionsSchema,
featureDependenciesSchema,
}; };
// Remove JSONSchema keys that would result in an invalid OpenAPI spec. // Remove JSONSchema keys that would result in an invalid OpenAPI spec.

View File

@ -12,6 +12,8 @@ import { variantsSchema } from './variants-schema';
import { constraintSchema } from './constraint-schema'; import { constraintSchema } from './constraint-schema';
import { tagTypeSchema } from './tag-type-schema'; import { tagTypeSchema } from './tag-type-schema';
import { strategyVariantSchema } from './strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema';
import { featureDependenciesSchema } from './feature-dependencies-schema';
import { dependentFeatureSchema } from './dependent-feature-schema';
export const exportResultSchema = { export const exportResultSchema = {
$id: '#/components/schemas/exportResultSchema', $id: '#/components/schemas/exportResultSchema',
@ -166,6 +168,14 @@ export const exportResultSchema = {
$ref: '#/components/schemas/tagTypeSchema', $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: { components: {
schemas: { schemas: {
@ -182,6 +192,8 @@ export const exportResultSchema = {
parametersSchema, parametersSchema,
legalValueSchema, legalValueSchema,
tagTypeSchema, tagTypeSchema,
featureDependenciesSchema,
dependentFeatureSchema,
}, },
}, },
} as const; } as const;

View File

@ -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
>;

View File

@ -146,6 +146,11 @@ export interface IDependency {
enabled?: boolean; enabled?: boolean;
} }
export interface IFeatureDependency {
feature: string;
dependency: IDependency;
}
export type IStrategyVariant = Omit<IVariant, 'overrides'>; export type IStrategyVariant = Omit<IVariant, 'overrides'>;
export interface IEnvironment { export interface IEnvironment {