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 {
getChildren(parents: string[]): Promise<string[]>;
@ -6,6 +7,7 @@ export interface IDependentFeaturesReadModel {
// we're interested in the list of parents, not orphans
getOrphanParents(parentsAndChildren: string[]): Promise<string[]>;
getParents(child: string): Promise<IDependency[]>;
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
getParentOptions(child: string): Promise<string[]>;
hasDependencies(feature: string): Promise<boolean>;
}

View File

@ -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<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[]> {
const result = await this.db('features')
.where('features.name', child)

View File

@ -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<IFeatureDependency[]> {
return Promise.resolve([]);
}
getChildren(): Promise<string[]> {
return Promise.resolve([]);
}

View File

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

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 {
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,

View File

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

View File

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

View File

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

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;
}
export interface IFeatureDependency {
feature: string;
dependency: IDependency;
}
export type IStrategyVariant = Omit<IVariant, 'overrides'>;
export interface IEnvironment {