unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
490 lines • 24.5 kB
JavaScript
import { FeaturesExportedEvent, FeaturesImportedEvent, SYSTEM_USER, SYSTEM_USER_AUDIT, } from '../../types/index.js';
import { BadDataError } from '../../error/index.js';
import { isValidField } from './import-context-validation.js';
import { ImportPermissionsService, } from './import-permissions-service.js';
import { ImportValidationMessages } from './import-validation-messages.js';
import { findDuplicates } from '../../util/findDuplicates.js';
import groupBy from 'lodash.groupby';
import { allSettledWithRejection } from '../../util/allSettledWithRejection.js';
import { readFile } from '../../util/read-file.js';
export default class ExportImportService {
constructor(stores, { getLogger, flagResolver, }, { featureToggleService, strategyService, contextService, accessService, eventService, tagTypeService, featureTagService, dependentFeaturesService, featureLinkService, }, dependentFeaturesReadModel, segmentReadModel, featureLinksReadModel) {
this.isCustomStrategy = (supportedStrategies) => {
const customStrategies = supportedStrategies
.filter((s) => s.editable)
.map((strategy) => strategy.name);
return (featureStrategy) => customStrategies.includes(featureStrategy);
};
this.toggleStore = stores.featureToggleStore;
this.importTogglesStore = stores.importTogglesStore;
this.featureStrategiesStore = stores.featureStrategiesStore;
this.featureEnvironmentStore = stores.featureEnvironmentStore;
this.tagTypeStore = stores.tagTypeStore;
this.featureTagStore = stores.featureTagStore;
this.flagResolver = flagResolver;
this.featureToggleService = featureToggleService;
this.contextFieldStore = stores.contextFieldStore;
this.strategyService = strategyService;
this.contextService = contextService;
this.accessService = accessService;
this.eventService = eventService;
this.tagTypeService = tagTypeService;
this.featureTagService = featureTagService;
this.dependentFeaturesService = dependentFeaturesService;
this.featureLinkService = featureLinkService;
this.importPermissionsService = new ImportPermissionsService(this.importTogglesStore, this.accessService, this.tagTypeService, this.contextService);
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.segmentReadModel = segmentReadModel;
this.featureLinksReadModel = featureLinksReadModel;
this.logger = getLogger('services/state-service.js');
}
async validate(dto, user, mode = 'regular') {
const [unsupportedStrategies, usedCustomStrategies, unsupportedContextFields, archivedFeatures, otherProjectFeatures, existingProjectFeatures, missingPermissions, duplicateFeatures, featureNameCheckResult, featureLimitResult, unsupportedSegments, unsupportedDependencies,] = await Promise.all([
this.getUnsupportedStrategies(dto),
this.getUsedCustomStrategies(dto),
this.getUnsupportedContextFields(dto),
this.getArchivedFeatures(dto),
this.getOtherProjectFeatures(dto),
this.getExistingProjectFeatures(dto),
this.importPermissionsService.getMissingPermissions(dto, user, mode),
this.getDuplicateFeatures(dto),
this.getInvalidFeatureNames(dto),
this.getFeatureLimit(dto),
this.getUnsupportedSegments(dto),
this.getMissingDependencies(dto),
]);
const errors = ImportValidationMessages.compileErrors({
projectName: dto.project,
strategies: unsupportedStrategies,
contextFields: unsupportedContextFields || [],
otherProjectFeatures,
duplicateFeatures,
featureNameCheckResult,
featureLimitResult,
segments: unsupportedSegments,
dependencies: unsupportedDependencies,
});
const warnings = ImportValidationMessages.compileWarnings({
archivedFeatures,
existingFeatures: existingProjectFeatures,
usedCustomStrategies,
});
const permissions = ImportValidationMessages.compilePermissionErrors(missingPermissions);
return {
errors,
warnings,
permissions,
};
}
async importVerify(dto, user, mode = 'regular') {
await allSettledWithRejection([
this.verifyStrategies(dto),
this.verifyContextFields(dto),
this.importPermissionsService.verifyPermissions(dto, user, mode),
this.verifyFeatures(dto),
this.verifySegments(dto),
this.verifyDependencies(dto),
]);
}
async fileImportVerify(dto) {
await allSettledWithRejection([
this.verifyStrategies(dto),
this.verifyContextFields(dto),
this.verifyFeatures(dto),
this.verifySegments(dto),
this.verifyDependencies(dto),
]);
}
async importFeatureData(dto, auditUser) {
await this.createOrUpdateToggles(dto, auditUser);
await this.importToggleVariants(dto, auditUser);
await this.importTagTypes(dto, auditUser);
await this.importTags(dto, auditUser);
await this.importContextFields(dto, auditUser);
await this.importLinks(dto, auditUser);
}
async import(dto, user, auditUser) {
const cleanedDto = await this.cleanData(dto);
await this.importVerify(cleanedDto, user);
await this.processImport(cleanedDto, user, auditUser);
}
async importFromFile(file, project, environment) {
const content = await readFile(file);
const data = JSON.parse(content);
const dto = {
project,
environment,
data,
};
const cleanedDto = await this.cleanData(dto);
await this.fileImportVerify(cleanedDto);
await this.processImport(cleanedDto, SYSTEM_USER, SYSTEM_USER_AUDIT);
}
async processImport(dto, user, auditUser) {
await this.importFeatureData(dto, auditUser);
await this.importEnvironmentData(dto, user, auditUser);
await this.eventService.storeEvent(new FeaturesImportedEvent({
project: dto.project,
environment: dto.environment,
auditUser,
}));
}
async importEnvironmentData(dto, user, auditUser) {
await this.deleteStrategies(dto);
await this.importStrategies(dto, auditUser);
await this.importToggleStatuses(dto, user, auditUser);
await this.importDependencies(dto, user, auditUser);
}
async importLinks(dto, auditUser) {
await this.importTogglesStore.deleteLinksForFeatures((dto.data.links ?? []).map((featureLink) => featureLink.feature));
const links = dto.data.links || [];
for (const featureLink of links) {
for (const link of featureLink.links) {
await this.featureLinkService.createLink(dto.project, {
featureName: featureLink.feature,
url: link.url,
title: link.title || undefined,
}, auditUser);
}
}
}
async importDependencies(dto, user, auditUser) {
await Promise.all((dto.data.dependencies || []).flatMap((dependency) => {
const feature = dto.data.features.find((feature) => feature.name === dependency.feature);
if (!feature || !feature.project) {
return [];
}
const projectId = feature.project;
return dependency.dependencies.map((parentDependency) => this.dependentFeaturesService.upsertFeatureDependency({
child: dependency.feature,
projectId,
}, parentDependency, user, auditUser));
}));
}
async importToggleStatuses(dto, user, auditUser) {
await Promise.all((dto.data.featureEnvironments || []).map((featureEnvironment) => this.featureToggleService.updateEnabled(dto.project, featureEnvironment.name, dto.environment, featureEnvironment.enabled, auditUser, user)));
}
async importStrategies(dto, auditUser) {
const hasFeatureName = (featureStrategy) => Boolean(featureStrategy.featureName);
await Promise.all(dto.data.featureStrategies
?.filter(hasFeatureName)
.map(({ featureName, ...restOfFeatureStrategy }) => this.featureToggleService.createStrategy(restOfFeatureStrategy, {
featureName,
environment: dto.environment,
projectId: dto.project,
}, auditUser)));
}
async deleteStrategies(dto) {
return this.importTogglesStore.deleteStrategiesForFeatures(dto.data.features.map((feature) => feature.name), dto.environment);
}
async importTags(dto, auditUser) {
await this.importTogglesStore.deleteTagsForFeatures(dto.data.features.map((feature) => feature.name));
const featureTags = dto.data.featureTags || [];
for (const tag of featureTags) {
if (tag.tagType) {
await this.featureTagService.addTag(tag.featureName, {
type: tag.tagType,
value: tag.tagValue,
}, auditUser);
}
}
}
async importContextFields(dto, auditUser) {
const newContextFields = (await this.getNewContextFields(dto)) || [];
await Promise.all(newContextFields.map((contextField) => this.contextService.createContextField({
name: contextField.name,
description: contextField.description,
legalValues: contextField.legalValues,
stickiness: contextField.stickiness,
}, auditUser)));
}
async importTagTypes(dto, auditUser) {
const newTagTypes = await this.getNewTagTypes(dto);
return Promise.all(newTagTypes.map((tagType) => {
return tagType
? this.tagTypeService.createTagType(tagType, auditUser)
: Promise.resolve();
}));
}
async importToggleVariants(dto, auditUser) {
const featureEnvsWithVariants = dto.data.featureEnvironments?.filter((featureEnvironment) => Array.isArray(featureEnvironment.variants) &&
featureEnvironment.variants.length > 0) || [];
await Promise.all(featureEnvsWithVariants.map((featureEnvironment) => {
return featureEnvironment.featureName
? this.featureToggleService.legacySaveVariantsOnEnv(dto.project, featureEnvironment.featureName, dto.environment, featureEnvironment.variants, auditUser)
: Promise.resolve();
}));
}
async createOrUpdateToggles(dto, auditUser) {
const existingFeatures = await this.getExistingProjectFeatures(dto);
for (const feature of dto.data.features) {
if (existingFeatures.includes(feature.name)) {
const { archivedAt, createdAt, ...rest } = feature;
await this.featureToggleService.updateFeatureToggle(dto.project, rest, feature.name, auditUser);
}
else {
await this.featureToggleService.validateName(feature.name);
const { archivedAt, createdAt, ...rest } = feature;
await this.featureToggleService.createFeatureToggle(dto.project, rest, auditUser);
}
}
}
async getUnsupportedSegments(dto) {
const supportedSegments = await this.segmentReadModel.getAll();
const targetProject = dto.project;
return dto.data.segments
? dto.data.segments
.filter((importingSegment) => !supportedSegments.find((existingSegment) => importingSegment.name ===
existingSegment.name &&
(!existingSegment.project ||
existingSegment.project ===
targetProject)))
.map((it) => it.name)
: [];
}
async getMissingDependencies(dto) {
const dependentFeatures = dto.data.dependencies?.flatMap((dependency) => dependency.dependencies.map((d) => d.feature)) || [];
const importedFeatures = dto.data.features.map((f) => f.name);
const missingFromImported = dependentFeatures.filter((feature) => !importedFeatures.includes(feature));
let missingFeatures = [];
if (missingFromImported.length) {
const featuresFromStore = (await this.toggleStore.getAllByNames(missingFromImported)).map((f) => f.name);
missingFeatures = missingFromImported.filter((feature) => !featuresFromStore.includes(feature));
}
return missingFeatures;
}
async verifySegments(dto) {
const unsupportedSegments = await this.getUnsupportedSegments(dto);
if (unsupportedSegments.length > 0) {
throw new BadDataError(`Unsupported segments: ${unsupportedSegments.join(', ')}`);
}
}
async verifyDependencies(dto) {
const unsupportedDependencies = await this.getMissingDependencies(dto);
if (unsupportedDependencies.length > 0) {
throw new BadDataError(`The following dependent features are missing: ${unsupportedDependencies.join(', ')}`);
}
}
async verifyContextFields(dto) {
const unsupportedContextFields = await this.getUnsupportedContextFields(dto);
if (Array.isArray(unsupportedContextFields)) {
const [firstError, ...remainingErrors] = unsupportedContextFields.map((field) => {
const description = `${field.name} is not supported.`;
return {
description,
message: description,
};
});
if (firstError !== undefined) {
throw new BadDataError('Some of the context fields you are trying to import are not supported.', [firstError, ...remainingErrors]);
}
}
}
async verifyFeatures(dto) {
const otherProjectFeatures = await this.getOtherProjectFeatures(dto);
if (otherProjectFeatures.length > 0) {
throw new BadDataError(`These features exist already in other projects: ${otherProjectFeatures.join(', ')}`);
}
}
async cleanData(dto) {
const removedFeaturesDto = await this.removeArchivedFeatures(dto);
return this.remapSegments(removedFeaturesDto);
}
async remapSegments(dto) {
const existingSegments = await this.segmentReadModel.getAll();
const segmentMapping = new Map(dto.data.segments?.map((segment) => [
segment.id,
existingSegments.find((existingSegment) => existingSegment.name === segment.name)?.id,
]));
return {
...dto,
data: {
...dto.data,
featureStrategies: dto.data.featureStrategies.map((strategy) => ({
...strategy,
segments: strategy.segments?.map((segment) => segmentMapping.get(segment)),
})),
},
};
}
async removeArchivedFeatures(dto) {
const archivedFeatures = await this.getArchivedFeatures(dto);
const featureTags = dto.data.featureTags?.filter((tag) => !archivedFeatures.includes(tag.featureName)) || [];
return {
...dto,
data: {
...dto.data,
features: dto.data.features.filter((feature) => !archivedFeatures.includes(feature.name)),
featureEnvironments: dto.data.featureEnvironments?.filter((environment) => environment.featureName &&
!archivedFeatures.includes(environment.featureName)),
featureStrategies: dto.data.featureStrategies.filter((strategy) => strategy.featureName &&
!archivedFeatures.includes(strategy.featureName)),
featureTags,
tagTypes: dto.data.tagTypes?.filter((tagType) => featureTags
.map((tag) => tag.tagType)
.includes(tagType.name)),
},
};
}
async verifyStrategies(dto) {
const unsupportedStrategies = await this.getUnsupportedStrategies(dto);
const [firstError, ...remainingErrors] = unsupportedStrategies.map((strategy) => {
const description = `${strategy.name} is not supported.`;
return {
description,
message: description,
};
});
if (firstError !== undefined) {
throw new BadDataError('Some of the strategies you are trying to import are not supported.', [firstError, ...remainingErrors]);
}
}
async getInvalidFeatureNames({ project, data, }) {
return this.featureToggleService.checkFeatureFlagNamesAgainstProjectPattern(project, data.features.map((f) => f.name));
}
async getFeatureLimit({ project, data, }) {
return this.importTogglesStore.getProjectFeaturesLimit([...new Set(data.features.map((f) => f.name))], project);
}
async getUnsupportedStrategies(dto) {
const supportedStrategies = await this.strategyService.getStrategies();
return dto.data.featureStrategies.filter((featureStrategy) => !supportedStrategies.find((strategy) => featureStrategy.name === strategy.name));
}
async getUsedCustomStrategies(dto) {
const supportedStrategies = await this.strategyService.getStrategies();
const uniqueFeatureStrategies = [
...new Set(dto.data.featureStrategies.map((strategy) => strategy.name)),
];
return uniqueFeatureStrategies.filter(this.isCustomStrategy(supportedStrategies));
}
async getUnsupportedContextFields(dto) {
const availableContextFields = await this.contextService.getAll();
return dto.data.contextFields?.filter((contextField) => !isValidField(contextField, availableContextFields));
}
async getArchivedFeatures(dto) {
return this.importTogglesStore.getArchivedFeatures(dto.data.features.map((feature) => feature.name));
}
async getOtherProjectFeatures(dto) {
const otherProjectsFeatures = await this.importTogglesStore.getFeaturesInOtherProjects(dto.data.features.map((feature) => feature.name), dto.project);
return otherProjectsFeatures.map((it) => `${it.name} (in project ${it.project})`);
}
async getExistingProjectFeatures(dto) {
return this.importTogglesStore.getFeaturesInProject(dto.data.features.map((feature) => feature.name), dto.project);
}
getDuplicateFeatures(dto) {
return findDuplicates(dto.data.features.map((feature) => feature.name));
}
async getNewTagTypes(dto) {
const existingTagTypes = (await this.tagTypeService.getAll()).map((tagType) => tagType.name);
const newTagTypes = (dto.data.tagTypes || []).filter((tagType) => !existingTagTypes.includes(tagType.name));
return [
...new Map(newTagTypes.map((item) => [item.name, item])).values(),
];
}
async getNewContextFields(dto) {
const availableContextFields = await this.contextService.getAll();
return dto.data.contextFields?.filter((contextField) => !availableContextFields.some((availableField) => availableField.name === contextField.name));
}
async export(query, auditUser) {
let featureNames = [];
if (typeof query.tag === 'string') {
featureNames = await this.featureTagService.listFeatures(query.tag);
}
else if (Array.isArray(query.features) && query.features.length) {
featureNames = query.features;
}
else if (typeof query.project === 'string') {
const allProjectFeatures = await this.toggleStore.getAll({
project: query.project,
});
featureNames = allProjectFeatures.map((feature) => feature.name);
}
else {
const allFeatures = await this.toggleStore.getAll();
featureNames = allFeatures.map((feature) => feature.name);
}
const [features, featureEnvironments, featureStrategies, strategySegments, contextFields, featureTags, segments, tagTypes, featureDependencies, featureLinks,] = await Promise.all([
this.toggleStore.getAllByNames(featureNames),
await this.featureEnvironmentStore.getAllByFeatures(featureNames, query.environment),
this.featureStrategiesStore.getAllByFeatures(featureNames, query.environment),
this.segmentReadModel.getAllFeatureStrategySegments(),
this.contextFieldStore.getAll(),
this.featureTagStore.getAllByFeatures(featureNames),
this.segmentReadModel.getAll(),
this.tagTypeStore.getAll(),
this.dependentFeaturesReadModel.getDependencies(featureNames),
this.featureLinksReadModel.getLinks(...featureNames),
]);
this.addSegmentsToStrategies(featureStrategies, strategySegments);
const filteredContextFields = contextFields
.filter((field) => featureEnvironments.some((featureEnv) => featureEnv.variants?.some((variant) => variant.stickiness === field.name ||
variant.overrides?.some((override) => override.contextName === field.name))) ||
featureStrategies.some((strategy) => strategy.parameters.stickiness === field.name ||
strategy.constraints.some((constraint) => constraint.contextName === field.name)))
.map((item) => {
const { usedInFeatures, usedInProjects, ...rest } = item;
return rest;
});
const filteredSegments = segments.filter((segment) => featureStrategies.some((strategy) => strategy.segments?.includes(segment.id)));
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 groupedFeatureLinks = groupBy(featureLinks, 'feature');
const mappedFeatureLinks = Object.entries(groupedFeatureLinks).map(([feature, links]) => ({
feature,
links: links.map((link) => ({
id: link.id,
url: link.url,
title: link.title,
})),
}));
const result = {
features: features.map((item) => {
const { createdAt, archivedAt, lastSeenAt, ...rest } = item;
return rest;
}),
featureStrategies: featureStrategies.map((item) => {
const name = item.strategyName;
const { createdAt, projectId, environment, strategyName, milestoneId, ...rest } = item;
return {
name,
...rest,
};
}),
featureEnvironments: featureEnvironments.map((item) => {
const { lastSeenAt, ...rest } = item;
return {
...rest,
name: item.featureName,
};
}),
contextFields: filteredContextFields.map((item) => {
const { createdAt, ...rest } = item;
return rest;
}),
featureTags,
segments: filteredSegments.map((item) => {
const { id, name } = item;
return {
id,
name,
};
}),
tagTypes: filteredTagTypes,
dependencies: mappedFeatureDependencies,
links: mappedFeatureLinks,
};
await this.eventService.storeEvent(new FeaturesExportedEvent({ data: result, auditUser }));
return result;
}
addSegmentsToStrategies(featureStrategies, strategySegments) {
featureStrategies.forEach((featureStrategy) => {
featureStrategy.segments = strategySegments
.filter((segment) => segment.featureStrategyId === featureStrategy.id)
.map((segment) => segment.segmentId);
});
}
}
//# sourceMappingURL=export-import-service.js.map