diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx
index 7981566790..aba5ce3aa0 100644
--- a/frontend/src/component/project/Project/Project.tsx
+++ b/frontend/src/component/project/Project/Project.tsx
@@ -145,11 +145,6 @@ export const Project = () => {
setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
@@ -159,34 +154,42 @@ export const Project = () => {
}
/>
-
- navigate(`/projects/${projectId}/edit`)
+
+ navigate(
+ `/projects/${projectId}/edit`
+ )
+ }
+ tooltipProps={{ title: 'Edit project' }}
+ data-loading
+ >
+
+
}
- tooltipProps={{ title: 'Edit project' }}
- data-loading
- >
-
-
- {
- setShowDelDialog(true);
- }}
- tooltipProps={{ title: 'Delete project' }}
- data-loading
- >
-
-
+ />
+ {
+ setShowDelDialog(true);
+ }}
+ tooltipProps={{
+ title: 'Delete project',
+ }}
+ data-loading
+ >
+
+
+ }
+ />
{
+ const { eventBus, getLogger } = config;
+ const eventStore = new EventStore(db, getLogger);
+ const groupStore = new GroupStore(db);
+ const accountStore = new AccountStore(db, getLogger);
+ const roleStore = new RoleStore(db, eventBus, getLogger);
+ const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
+ const accessStore = new AccessStore(db, eventBus, getLogger);
+ const groupService = new GroupService(
+ { groupStore, eventStore, accountStore },
+ { getLogger },
+ );
+
+ return new AccessService(
+ { accessStore, accountStore, roleStore, environmentStore },
+ { getLogger },
+ groupService,
+ );
+};
+
+export const createFakeAccessService = (
+ config: IUnleashConfig,
+): AccessService => {
+ const { getLogger } = config;
+ const eventStore = new FakeEventStore();
+ const groupStore = new FakeGroupStore();
+ const accountStore = new FakeAccountStore();
+ const roleStore = new FakeRoleStore();
+ const environmentStore = new FakeEnvironmentStore();
+ const accessStore = new FakeAccessStore();
+ const groupService = new GroupService(
+ { groupStore, eventStore, accountStore },
+ { getLogger },
+ );
+
+ return new AccessService(
+ { accessStore, accountStore, roleStore, environmentStore },
+ { getLogger },
+ groupService,
+ );
+};
diff --git a/src/lib/app.ts b/src/lib/app.ts
index 2be2c17110..232c8549ed 100644
--- a/src/lib/app.ts
+++ b/src/lib/app.ts
@@ -169,7 +169,7 @@ export default async function getApp(
}
// Setup API routes
- app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);
+ app.use(`${baseUriPath}/`, new IndexRouter(config, services, db).router);
if (services.openApiService) {
services.openApiService.useErrorHandler(app);
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 5c8ff3e13a..ac7a18be1a 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -36,6 +36,7 @@ import { FavoriteProjectsStore } from './favorite-projects-store';
import { AccountStore } from './account-store';
import ProjectStatsStore from './project-stats-store';
import { Db } from './db';
+import { ImportTogglesStore } from '../export-import-toggles/import-toggles-store';
export const createStores = (
config: IUnleashConfig,
@@ -115,6 +116,7 @@ export const createStores = (
getLogger,
),
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
+ importTogglesStore: new ImportTogglesStore(db),
};
};
diff --git a/src/lib/db/transaction.ts b/src/lib/db/transaction.ts
new file mode 100644
index 0000000000..7e8150fdb7
--- /dev/null
+++ b/src/lib/db/transaction.ts
@@ -0,0 +1,22 @@
+import { Knex } from 'knex';
+
+export type KnexTransaction = Knex.Transaction;
+
+export type MockTransaction = null;
+
+export type UnleashTransaction = KnexTransaction | MockTransaction;
+
+export type TransactionCreator = (
+ scope: (trx: S) => void | Promise,
+) => Promise;
+
+export const createKnexTransactionStarter = (
+ knex: Knex,
+): TransactionCreator => {
+ function transaction(
+ scope: (trx: KnexTransaction) => void | Promise,
+ ) {
+ return knex.transaction(scope);
+ }
+ return transaction;
+};
diff --git a/src/lib/export-import-toggles/export-import-controller.ts b/src/lib/export-import-toggles/export-import-controller.ts
new file mode 100644
index 0000000000..5e1cbb1c97
--- /dev/null
+++ b/src/lib/export-import-toggles/export-import-controller.ts
@@ -0,0 +1,177 @@
+import { Response } from 'express';
+import { Knex } from 'knex';
+import Controller from '../routes/controller';
+import { Logger } from '../logger';
+import ExportImportService from './export-import-service';
+import { OpenApiService } from '../services';
+import { TransactionCreator, UnleashTransaction } from '../db/transaction';
+import {
+ IUnleashConfig,
+ IUnleashServices,
+ NONE,
+ serializeDates,
+} from '../types';
+import {
+ createRequestSchema,
+ createResponseSchema,
+ emptyResponse,
+ ExportQuerySchema,
+ exportResultSchema,
+ ImportTogglesSchema,
+ importTogglesValidateSchema,
+} from '../openapi';
+import { IAuthRequest } from '../routes/unleash-types';
+import { extractUsername } from '../util';
+import { InvalidOperationError } from '../error';
+
+class ExportImportController extends Controller {
+ private logger: Logger;
+
+ private exportImportService: ExportImportService;
+
+ private transactionalExportImportService: (
+ db: Knex.Transaction,
+ ) => ExportImportService;
+
+ private openApiService: OpenApiService;
+
+ private readonly startTransaction: TransactionCreator;
+
+ constructor(
+ config: IUnleashConfig,
+ {
+ exportImportService,
+ transactionalExportImportService,
+ openApiService,
+ }: Pick<
+ IUnleashServices,
+ | 'exportImportService'
+ | 'openApiService'
+ | 'transactionalExportImportService'
+ >,
+ startTransaction: TransactionCreator,
+ ) {
+ super(config);
+ this.logger = config.getLogger('/admin-api/export-import.ts');
+ this.exportImportService = exportImportService;
+ this.transactionalExportImportService =
+ transactionalExportImportService;
+ this.startTransaction = startTransaction;
+ this.openApiService = openApiService;
+ this.route({
+ method: 'post',
+ path: '/export',
+ permission: NONE,
+ handler: this.export,
+ middleware: [
+ this.openApiService.validPath({
+ tags: ['Unstable'],
+ operationId: 'exportFeatures',
+ requestBody: createRequestSchema('exportQuerySchema'),
+ responses: {
+ 200: createResponseSchema('exportResultSchema'),
+ },
+ }),
+ ],
+ });
+ this.route({
+ method: 'post',
+ path: '/full-validate',
+ permission: NONE,
+ handler: this.validateImport,
+ middleware: [
+ openApiService.validPath({
+ summary:
+ 'Validate import of feature toggles for an environment in the project',
+ description: `Unleash toggles exported from a different instance can be imported into a new project and environment`,
+ tags: ['Unstable'],
+ operationId: 'validateImport',
+ requestBody: createRequestSchema('importTogglesSchema'),
+ responses: {
+ 200: createResponseSchema(
+ 'importTogglesValidateSchema',
+ ),
+ },
+ }),
+ ],
+ });
+ this.route({
+ method: 'post',
+ path: '/full-import',
+ permission: NONE,
+ handler: this.importData,
+ middleware: [
+ openApiService.validPath({
+ summary:
+ 'Import feature toggles for an environment in the project',
+ description: `Unleash toggles exported from a different instance can be imported into a new project and environment`,
+ tags: ['Unstable'],
+ operationId: 'importToggles',
+ requestBody: createRequestSchema('importTogglesSchema'),
+ responses: {
+ 200: emptyResponse,
+ },
+ }),
+ ],
+ });
+ }
+
+ async export(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ this.verifyExportImportEnabled();
+ const query = req.body;
+ const userName = extractUsername(req);
+ const data = await this.exportImportService.export(query, userName);
+
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ exportResultSchema.$id,
+ serializeDates(data),
+ );
+ }
+
+ async validateImport(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ this.verifyExportImportEnabled();
+ const dto = req.body;
+ const { user } = req;
+ const validation = await this.startTransaction(async (tx) =>
+ this.transactionalExportImportService(tx).validate(dto, user),
+ );
+
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ importTogglesValidateSchema.$id,
+ validation,
+ );
+ }
+
+ async importData(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ this.verifyExportImportEnabled();
+ const dto = req.body;
+ const { user } = req;
+ await this.startTransaction(async (tx) =>
+ this.transactionalExportImportService(tx).import(dto, user),
+ );
+
+ res.status(200).end();
+ }
+
+ private verifyExportImportEnabled() {
+ if (!this.config.flagResolver.isEnabled('featuresExportImport')) {
+ throw new InvalidOperationError(
+ 'Feature export/import is not enabled',
+ );
+ }
+ }
+}
+export default ExportImportController;
diff --git a/src/lib/export-import-toggles/export-import-service.ts b/src/lib/export-import-toggles/export-import-service.ts
new file mode 100644
index 0000000000..9aa9dce2a3
--- /dev/null
+++ b/src/lib/export-import-toggles/export-import-service.ts
@@ -0,0 +1,744 @@
+import { IUnleashConfig } from '../types/option';
+import {
+ 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 { IEventStore } from '../types/stores/event-store';
+import { IStrategy } from '../types/stores/strategy-store';
+import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
+import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
+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 {
+ CREATE_CONTEXT_FIELD,
+ CREATE_FEATURE,
+ CREATE_FEATURE_STRATEGY,
+ DELETE_FEATURE_STRATEGY,
+ FEATURES_EXPORTED,
+ FEATURES_IMPORTED,
+ IFlagResolver,
+ IUnleashServices,
+ UPDATE_FEATURE,
+ UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
+ UPDATE_TAG_TYPE,
+} from '../types';
+import {
+ ExportResultSchema,
+ FeatureStrategySchema,
+ ImportTogglesValidateItemSchema,
+ ImportTogglesValidateSchema,
+} from '../openapi';
+import { ImportTogglesSchema } from '../openapi/spec/import-toggles-schema';
+import User from '../types/user';
+import { IContextFieldDto } from '../types/stores/context-field-store';
+import { BadDataError, InvalidOperationError } from '../error';
+import { extractUsernameFromUser } from '../util';
+import {
+ AccessService,
+ ContextService,
+ FeatureTagService,
+ FeatureToggleService,
+ StrategyService,
+ TagTypeService,
+} from '../services';
+import { isValidField } from './import-context-validation';
+import { IImportTogglesStore } from './import-toggles-store-type';
+
+export default class ExportImportService {
+ private logger: Logger;
+
+ private toggleStore: IFeatureToggleStore;
+
+ private featureStrategiesStore: IFeatureStrategiesStore;
+
+ private eventStore: IEventStore;
+
+ private importTogglesStore: IImportTogglesStore;
+
+ private tagTypeStore: ITagTypeStore;
+
+ private featureEnvironmentStore: IFeatureEnvironmentStore;
+
+ private featureTagStore: IFeatureTagStore;
+
+ private segmentStore: ISegmentStore;
+
+ private flagResolver: IFlagResolver;
+
+ private featureToggleService: FeatureToggleService;
+
+ private contextFieldStore: IContextFieldStore;
+
+ private strategyService: StrategyService;
+
+ private contextService: ContextService;
+
+ private accessService: AccessService;
+
+ private tagTypeService: TagTypeService;
+
+ private featureTagService: FeatureTagService;
+
+ constructor(
+ stores: Pick<
+ IUnleashStores,
+ | 'importTogglesStore'
+ | 'eventStore'
+ | 'featureStrategiesStore'
+ | 'featureToggleStore'
+ | 'featureEnvironmentStore'
+ | 'tagTypeStore'
+ | 'featureTagStore'
+ | 'segmentStore'
+ | 'contextFieldStore'
+ >,
+ {
+ getLogger,
+ flagResolver,
+ }: Pick,
+ {
+ featureToggleService,
+ strategyService,
+ contextService,
+ accessService,
+ tagTypeService,
+ featureTagService,
+ }: Pick<
+ IUnleashServices,
+ | 'featureToggleService'
+ | 'strategyService'
+ | 'contextService'
+ | 'accessService'
+ | 'tagTypeService'
+ | 'featureTagService'
+ >,
+ ) {
+ this.eventStore = stores.eventStore;
+ 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.segmentStore = stores.segmentStore;
+ this.flagResolver = flagResolver;
+ this.featureToggleService = featureToggleService;
+ this.contextFieldStore = stores.contextFieldStore;
+ this.strategyService = strategyService;
+ this.contextService = contextService;
+ this.accessService = accessService;
+ this.tagTypeService = tagTypeService;
+ this.featureTagService = featureTagService;
+ this.logger = getLogger('services/state-service.js');
+ }
+
+ async validate(
+ dto: ImportTogglesSchema,
+ user: User,
+ ): Promise {
+ const [
+ unsupportedStrategies,
+ usedCustomStrategies,
+ unsupportedContextFields,
+ archivedFeatures,
+ otherProjectFeatures,
+ missingPermissions,
+ ] = await Promise.all([
+ this.getUnsupportedStrategies(dto),
+ this.getUsedCustomStrategies(dto),
+ this.getUnsupportedContextFields(dto),
+ this.getArchivedFeatures(dto),
+ this.getOtherProjectFeatures(dto),
+ this.getMissingPermissions(dto, user),
+ ]);
+
+ const errors = this.compileErrors(
+ dto.project,
+ unsupportedStrategies,
+ unsupportedContextFields,
+ otherProjectFeatures,
+ );
+ const warnings = this.compileWarnings(
+ usedCustomStrategies,
+ archivedFeatures,
+ );
+ const permissions = this.compilePermissionErrors(missingPermissions);
+
+ return {
+ errors,
+ warnings,
+ permissions,
+ };
+ }
+
+ async import(dto: ImportTogglesSchema, user: User): Promise {
+ const cleanedDto = await this.cleanData(dto);
+
+ await Promise.all([
+ this.verifyStrategies(cleanedDto),
+ this.verifyContextFields(cleanedDto),
+ this.verifyPermissions(dto, user),
+ this.verifyFeatures(dto),
+ ]);
+ await this.createToggles(cleanedDto, user);
+ await this.importToggleVariants(dto, user);
+ await this.importTagTypes(cleanedDto, user);
+ await this.importTags(cleanedDto, user);
+ await this.importContextFields(dto, user);
+
+ await this.importDefault(cleanedDto, user);
+ await this.eventStore.store({
+ project: cleanedDto.project,
+ environment: cleanedDto.environment,
+ type: FEATURES_IMPORTED,
+ createdBy: extractUsernameFromUser(user),
+ });
+ }
+
+ private async importDefault(dto: ImportTogglesSchema, user: User) {
+ await this.deleteStrategies(dto);
+ await this.importStrategies(dto, user);
+ await this.importToggleStatuses(dto, user);
+ }
+
+ private async importToggleStatuses(dto: ImportTogglesSchema, user: User) {
+ await Promise.all(
+ dto.data.featureEnvironments?.map((featureEnvironment) =>
+ this.featureToggleService.updateEnabled(
+ dto.project,
+ featureEnvironment.name,
+ dto.environment,
+ featureEnvironment.enabled,
+ extractUsernameFromUser(user),
+ user,
+ ),
+ ),
+ );
+ }
+
+ private async importStrategies(dto: ImportTogglesSchema, user: User) {
+ await Promise.all(
+ dto.data.featureStrategies?.map((featureStrategy) =>
+ this.featureToggleService.createStrategy(
+ {
+ name: featureStrategy.name,
+ constraints: featureStrategy.constraints,
+ parameters: featureStrategy.parameters,
+ segments: featureStrategy.segments,
+ sortOrder: featureStrategy.sortOrder,
+ },
+ {
+ featureName: featureStrategy.featureName,
+ environment: dto.environment,
+ projectId: dto.project,
+ },
+ extractUsernameFromUser(user),
+ ),
+ ),
+ );
+ }
+
+ private async deleteStrategies(dto: ImportTogglesSchema) {
+ return this.importTogglesStore.deleteStrategiesForFeatures(
+ dto.data.features.map((feature) => feature.name),
+ dto.environment,
+ );
+ }
+
+ private async importTags(dto: ImportTogglesSchema, user: User) {
+ await this.importTogglesStore.deleteTagsForFeatures(
+ dto.data.features.map((feature) => feature.name),
+ );
+ return Promise.all(
+ dto.data.featureTags?.map((tag) =>
+ this.featureTagService.addTag(
+ tag.featureName,
+ { type: tag.tagType, value: tag.tagValue },
+ extractUsernameFromUser(user),
+ ),
+ ),
+ );
+ }
+
+ private async importContextFields(dto: ImportTogglesSchema, user: User) {
+ 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,
+ },
+ extractUsernameFromUser(user),
+ ),
+ ),
+ );
+ }
+
+ private async importTagTypes(dto: ImportTogglesSchema, user: User) {
+ const newTagTypes = await this.getNewTagTypes(dto);
+ return Promise.all(
+ newTagTypes.map((tagType) =>
+ this.tagTypeService.createTagType(
+ tagType,
+ extractUsernameFromUser(user),
+ ),
+ ),
+ );
+ }
+
+ private async importToggleVariants(dto: ImportTogglesSchema, user: User) {
+ const featureEnvsWithVariants = dto.data.featureEnvironments?.filter(
+ (featureEnvironment) => featureEnvironment.variants?.length > 0,
+ );
+ await Promise.all(
+ featureEnvsWithVariants.map((featureEnvironment) =>
+ this.featureToggleService.saveVariantsOnEnv(
+ dto.project,
+ featureEnvironment.featureName,
+ dto.environment,
+ featureEnvironment.variants as IVariant[],
+ user,
+ ),
+ ),
+ );
+ }
+
+ private async createToggles(dto: ImportTogglesSchema, user: User) {
+ await Promise.all(
+ dto.data.features.map((feature) =>
+ this.featureToggleService
+ .validateName(feature.name)
+ .then(() => {
+ const { archivedAt, createdAt, ...rest } = feature;
+ return this.featureToggleService.createFeatureToggle(
+ dto.project,
+ rest as FeatureToggleDTO,
+ extractUsernameFromUser(user),
+ );
+ })
+ .catch(() => {}),
+ ),
+ );
+ }
+
+ private async verifyContextFields(dto: ImportTogglesSchema) {
+ const unsupportedContextFields = await this.getUnsupportedContextFields(
+ dto,
+ );
+ if (unsupportedContextFields.length > 0) {
+ throw new BadDataError(
+ `Context fields with errors: ${unsupportedContextFields
+ .map((field) => field.name)
+ .join(', ')}`,
+ );
+ }
+ }
+
+ private async verifyPermissions(dto: ImportTogglesSchema, user: User) {
+ const missingPermissions = await this.getMissingPermissions(dto, user);
+ if (missingPermissions.length > 0) {
+ throw new InvalidOperationError(
+ 'You are missing permissions to import',
+ );
+ }
+ }
+
+ private async verifyFeatures(dto: ImportTogglesSchema) {
+ const otherProjectFeatures = await this.getOtherProjectFeatures(dto);
+ if (otherProjectFeatures.length > 0) {
+ throw new BadDataError(
+ `These features exist already in other projects: ${otherProjectFeatures.join(
+ ', ',
+ )}`,
+ );
+ }
+ }
+
+ private async cleanData(dto: ImportTogglesSchema) {
+ const removedFeaturesDto = await this.removeArchivedFeatures(dto);
+ const remappedDto = this.remapSegments(removedFeaturesDto);
+ return remappedDto;
+ }
+
+ private async remapSegments(dto: ImportTogglesSchema) {
+ return {
+ ...dto,
+ data: {
+ ...dto.data,
+ featureStrategies: dto.data.featureStrategies.map(
+ (strategy) => ({
+ ...strategy,
+ segments: [],
+ }),
+ ),
+ },
+ };
+ }
+
+ private async removeArchivedFeatures(dto: ImportTogglesSchema) {
+ 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) =>
+ !archivedFeatures.includes(environment.featureName),
+ ),
+ featureStrategies: dto.data.featureStrategies.filter(
+ (strategy) =>
+ !archivedFeatures.includes(strategy.featureName),
+ ),
+ featureTags,
+ tagTypes: dto.data.tagTypes?.filter((tagType) =>
+ featureTags
+ .map((tag) => tag.tagType)
+ .includes(tagType.name),
+ ),
+ },
+ };
+ }
+
+ private async verifyStrategies(dto: ImportTogglesSchema) {
+ const unsupportedStrategies = await this.getUnsupportedStrategies(dto);
+ if (unsupportedStrategies.length > 0) {
+ throw new BadDataError(
+ `Unsupported strategies: ${unsupportedStrategies
+ .map((strategy) => strategy.name)
+ .join(', ')}`,
+ );
+ }
+ }
+
+ private compileErrors(
+ projectName: string,
+ strategies: FeatureStrategySchema[],
+ contextFields: IContextFieldDto[],
+ otherProjectFeatures: string[],
+ ) {
+ const errors: ImportTogglesValidateItemSchema[] = [];
+
+ if (strategies.length > 0) {
+ errors.push({
+ message:
+ 'We detected the following custom strategy in the import file that needs to be created first:',
+ affectedItems: strategies.map((strategy) => strategy.name),
+ });
+ }
+ if (contextFields.length > 0) {
+ errors.push({
+ message:
+ 'We detected the following context fields that do not have matching legal values with the imported ones:',
+ affectedItems: contextFields.map(
+ (contextField) => contextField.name,
+ ),
+ });
+ }
+ if (otherProjectFeatures.length > 0) {
+ errors.push({
+ message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`,
+ affectedItems: otherProjectFeatures,
+ });
+ }
+
+ return errors;
+ }
+
+ private compileWarnings(
+ usedCustomStrategies: string[],
+ archivedFeatures: string[],
+ ) {
+ const warnings: ImportTogglesValidateItemSchema[] = [];
+ if (usedCustomStrategies.length > 0) {
+ warnings.push({
+ message:
+ 'The following strategy types will be used in import. Please make sure the strategy type parameters are configured as in source environment:',
+ affectedItems: usedCustomStrategies,
+ });
+ }
+ if (archivedFeatures.length > 0) {
+ warnings.push({
+ message:
+ 'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
+ affectedItems: archivedFeatures,
+ });
+ }
+ return warnings;
+ }
+
+ private compilePermissionErrors(missingPermissions: string[]) {
+ const errors: ImportTogglesValidateItemSchema[] = [];
+ if (missingPermissions.length > 0) {
+ errors.push({
+ message:
+ 'We detected you are missing the following permissions:',
+ affectedItems: missingPermissions,
+ });
+ }
+
+ return errors;
+ }
+
+ private async getUnsupportedStrategies(
+ dto: ImportTogglesSchema,
+ ): Promise {
+ const supportedStrategies = await this.strategyService.getStrategies();
+ return dto.data.featureStrategies.filter(
+ (featureStrategy) =>
+ !supportedStrategies.find(
+ (strategy) => featureStrategy.name === strategy.name,
+ ),
+ );
+ }
+
+ private async getUsedCustomStrategies(dto: ImportTogglesSchema) {
+ const supportedStrategies = await this.strategyService.getStrategies();
+ const uniqueFeatureStrategies = [
+ ...new Set(
+ dto.data.featureStrategies.map((strategy) => strategy.name),
+ ),
+ ];
+ return uniqueFeatureStrategies.filter(
+ this.isCustomStrategy(supportedStrategies),
+ );
+ }
+
+ isCustomStrategy = (
+ supportedStrategies: IStrategy[],
+ ): ((x: string) => boolean) => {
+ const customStrategies = supportedStrategies
+ .filter((s) => s.editable)
+ .map((strategy) => strategy.name);
+ return (featureStrategy) => customStrategies.includes(featureStrategy);
+ };
+
+ private async getUnsupportedContextFields(dto: ImportTogglesSchema) {
+ const availableContextFields = await this.contextService.getAll();
+
+ return dto.data.contextFields?.filter(
+ (contextField) =>
+ !isValidField(contextField, availableContextFields),
+ );
+ }
+
+ private async getArchivedFeatures(dto: ImportTogglesSchema) {
+ return this.importTogglesStore.getArchivedFeatures(
+ dto.data.features.map((feature) => feature.name),
+ );
+ }
+
+ private async getOtherProjectFeatures(dto: ImportTogglesSchema) {
+ 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})`,
+ );
+ }
+
+ private async getMissingPermissions(
+ dto: ImportTogglesSchema,
+ user: User,
+ ): Promise {
+ const requiredImportPermission = [
+ CREATE_FEATURE,
+ UPDATE_FEATURE,
+ DELETE_FEATURE_STRATEGY,
+ CREATE_FEATURE_STRATEGY,
+ UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
+ ];
+ const [newTagTypes, newContextFields] = await Promise.all([
+ this.getNewTagTypes(dto),
+ this.getNewContextFields(dto),
+ ]);
+ const permissions = [...requiredImportPermission];
+ if (newTagTypes.length > 0) {
+ permissions.push(UPDATE_TAG_TYPE);
+ }
+ if (newContextFields.length > 0) {
+ permissions.push(CREATE_CONTEXT_FIELD);
+ }
+
+ const displayPermissions =
+ await this.importTogglesStore.getDisplayPermissions(permissions);
+
+ const results = await Promise.all(
+ displayPermissions.map((permission) =>
+ this.accessService
+ .hasPermission(
+ user,
+ permission.name,
+ dto.project,
+ dto.environment,
+ )
+ .then(
+ (hasPermission) => [permission, hasPermission] as const,
+ ),
+ ),
+ );
+ return results
+ .filter(([, hasAccess]) => !hasAccess)
+ .map(([permission]) => permission.displayName);
+ }
+
+ private async getNewTagTypes(dto: ImportTogglesSchema) {
+ const existingTagTypes = (await this.tagTypeService.getAll()).map(
+ (tagType) => tagType.name,
+ );
+ const newTagTypes = dto.data.tagTypes?.filter(
+ (tagType) => !existingTagTypes.includes(tagType.name),
+ );
+ const uniqueTagTypes = [
+ ...new Map(newTagTypes.map((item) => [item.name, item])).values(),
+ ];
+ return uniqueTagTypes;
+ }
+
+ private async getNewContextFields(dto: ImportTogglesSchema) {
+ const availableContextFields = await this.contextService.getAll();
+
+ return dto.data.contextFields?.filter(
+ (contextField) =>
+ !availableContextFields.some(
+ (availableField) =>
+ availableField.name === contextField.name,
+ ),
+ );
+ }
+
+ async export(
+ query: ExportQuerySchema,
+ userName: string,
+ ): Promise {
+ const [
+ features,
+ featureEnvironments,
+ featureStrategies,
+ strategySegments,
+ contextFields,
+ featureTags,
+ segments,
+ tagTypes,
+ ] = await Promise.all([
+ this.toggleStore.getAllByNames(query.features),
+ await this.featureEnvironmentStore.getAllByFeatures(
+ query.features,
+ query.environment,
+ ),
+ this.featureStrategiesStore.getAllByFeatures(
+ query.features,
+ query.environment,
+ ),
+ this.segmentStore.getAllFeatureStrategySegments(),
+ this.contextFieldStore.getAll(),
+ this.featureTagStore.getAllByFeatures(query.features),
+ this.segmentStore.getAll(),
+ this.tagTypeStore.getAll(),
+ ]);
+ 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,
+ ),
+ ),
+ );
+ 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 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,
+ ...rest
+ } = item;
+ return {
+ name,
+ ...rest,
+ };
+ }),
+ featureEnvironments: featureEnvironments.map((item) => ({
+ ...item,
+ 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,
+ };
+ await this.eventStore.store({
+ type: FEATURES_EXPORTED,
+ createdBy: userName,
+ data: result,
+ });
+
+ return result;
+ }
+
+ addSegmentsToStrategies(
+ featureStrategies: IFeatureStrategy[],
+ strategySegments: IFeatureStrategySegment[],
+ ): void {
+ featureStrategies.forEach((featureStrategy) => {
+ featureStrategy.segments = strategySegments
+ .filter(
+ (segment) =>
+ segment.featureStrategyId === featureStrategy.id,
+ )
+ .map((segment) => segment.segmentId);
+ });
+ }
+}
+
+module.exports = ExportImportService;
diff --git a/src/lib/export-import-toggles/import-context-validation.test.ts b/src/lib/export-import-toggles/import-context-validation.test.ts
new file mode 100644
index 0000000000..27ed908b7e
--- /dev/null
+++ b/src/lib/export-import-toggles/import-context-validation.test.ts
@@ -0,0 +1,33 @@
+import { isValidField } from './import-context-validation';
+
+test('has value context field', () => {
+ expect(
+ isValidField(
+ { name: 'contextField', legalValues: [{ value: 'value1' }] },
+ [{ name: 'contextField', legalValues: [{ value: 'value1' }] }],
+ ),
+ ).toBe(true);
+});
+
+test('no matching field value', () => {
+ expect(
+ isValidField(
+ { name: 'contextField', legalValues: [{ value: 'value1' }] },
+ [{ name: 'contextField', legalValues: [{ value: 'value2' }] }],
+ ),
+ ).toBe(false);
+});
+
+test('subset field value', () => {
+ expect(
+ isValidField(
+ { name: 'contextField', legalValues: [{ value: 'value1' }] },
+ [
+ {
+ name: 'contextField',
+ legalValues: [{ value: 'value2' }, { value: 'value1' }],
+ },
+ ],
+ ),
+ ).toBe(true);
+});
diff --git a/src/lib/export-import-toggles/import-context-validation.ts b/src/lib/export-import-toggles/import-context-validation.ts
new file mode 100644
index 0000000000..b96e016d5f
--- /dev/null
+++ b/src/lib/export-import-toggles/import-context-validation.ts
@@ -0,0 +1,16 @@
+import { IContextFieldDto } from '../types/stores/context-field-store';
+
+export const isValidField = (
+ importedField: IContextFieldDto,
+ existingFields: IContextFieldDto[],
+): boolean => {
+ const matchingExistingField = existingFields.find(
+ (field) => field.name === importedField.name,
+ );
+ if (!matchingExistingField) {
+ return true;
+ }
+ return importedField.legalValues.every((value) =>
+ matchingExistingField.legalValues.find((v) => v.value === value.value),
+ );
+};
diff --git a/src/lib/export-import-toggles/import-toggles-store-type.ts b/src/lib/export-import-toggles/import-toggles-store-type.ts
new file mode 100644
index 0000000000..52daeb8972
--- /dev/null
+++ b/src/lib/export-import-toggles/import-toggles-store-type.ts
@@ -0,0 +1,19 @@
+export interface IImportTogglesStore {
+ deleteStrategiesForFeatures(
+ featureNames: string[],
+ environment: string,
+ ): Promise;
+
+ getArchivedFeatures(featureNames: string[]): Promise;
+
+ getFeaturesInOtherProjects(
+ featureNames: string[],
+ project: string,
+ ): Promise<{ name: string; project: string }[]>;
+
+ deleteTagsForFeatures(tags: string[]): Promise;
+
+ getDisplayPermissions(
+ names: string[],
+ ): Promise<{ name: string; displayName: string }[]>;
+}
diff --git a/src/lib/export-import-toggles/import-toggles-store.ts b/src/lib/export-import-toggles/import-toggles-store.ts
new file mode 100644
index 0000000000..93cfce34ae
--- /dev/null
+++ b/src/lib/export-import-toggles/import-toggles-store.ts
@@ -0,0 +1,60 @@
+import { IImportTogglesStore } from './import-toggles-store-type';
+import { Knex } from 'knex';
+
+const T = {
+ featureStrategies: 'feature_strategies',
+ features: 'features',
+ feature_tag: 'feature_tag',
+};
+export class ImportTogglesStore implements IImportTogglesStore {
+ private db: Knex;
+
+ constructor(db: Knex) {
+ this.db = db;
+ }
+
+ async getDisplayPermissions(
+ names: string[],
+ ): Promise<{ name: string; displayName: string }[]> {
+ const rows = await this.db
+ .from('permissions')
+ .whereIn('permission', names);
+ return rows.map((row) => ({
+ name: row.permission,
+ displayName: row.display_name,
+ }));
+ }
+
+ async deleteStrategiesForFeatures(
+ featureNames: string[],
+ environment: string,
+ ): Promise {
+ return this.db(T.featureStrategies)
+ .where({ environment })
+ .whereIn('feature_name', featureNames)
+ .del();
+ }
+
+ async getArchivedFeatures(featureNames: string[]): Promise {
+ const rows = await this.db(T.features)
+ .select('name')
+ .whereNot('archived_at', null)
+ .whereIn('name', featureNames);
+ return rows.map((row) => row.name);
+ }
+
+ async getFeaturesInOtherProjects(
+ featureNames: string[],
+ project: string,
+ ): Promise<{ name: string; project: string }[]> {
+ const rows = await this.db(T.features)
+ .select(['name', 'project'])
+ .whereNot('project', project)
+ .whereIn('name', featureNames);
+ return rows.map((row) => ({ name: row.name, project: row.project }));
+ }
+
+ async deleteTagsForFeatures(tags: string[]): Promise {
+ return this.db(T.feature_tag).whereIn('feature_name', tags).del();
+ }
+}
diff --git a/src/lib/export-import-toggles/index.ts b/src/lib/export-import-toggles/index.ts
new file mode 100644
index 0000000000..f65c3285df
--- /dev/null
+++ b/src/lib/export-import-toggles/index.ts
@@ -0,0 +1,192 @@
+import { Db } from '../db/db';
+import { IUnleashConfig } from '../types';
+import ExportImportService from './export-import-service';
+import { ImportTogglesStore } from './import-toggles-store';
+import FeatureToggleStore from '../db/feature-toggle-store';
+import TagStore from '../db/tag-store';
+import TagTypeStore from '../db/tag-type-store';
+import ProjectStore from '../db/project-store';
+import FeatureTagStore from '../db/feature-tag-store';
+import StrategyStore from '../db/strategy-store';
+import ContextFieldStore from '../db/context-field-store';
+import EventStore from '../db/event-store';
+import FeatureStrategiesStore from '../db/feature-strategy-store';
+import {
+ ContextService,
+ FeatureTagService,
+ StrategyService,
+ TagTypeService,
+} from '../services';
+import { createAccessService, createFakeAccessService } from '../access';
+import {
+ createFakeFeatureToggleService,
+ createFeatureToggleService,
+} from '../feature-toggle';
+import SegmentStore from '../db/segment-store';
+import { FeatureEnvironmentStore } from '../db/feature-environment-store';
+import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store';
+import FakeTagStore from '../../test/fixtures/fake-tag-store';
+import FakeTagTypeStore from '../../test/fixtures/fake-tag-type-store';
+import FakeSegmentStore from '../../test/fixtures/fake-segment-store';
+import FakeProjectStore from '../../test/fixtures/fake-project-store';
+import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
+import FakeContextFieldStore from '../../test/fixtures/fake-context-field-store';
+import FakeEventStore from '../../test/fixtures/fake-event-store';
+import FakeFeatureStrategiesStore from '../../test/fixtures/fake-feature-strategies-store';
+import FakeFeatureEnvironmentStore from '../../test/fixtures/fake-feature-environment-store';
+import FakeStrategiesStore from '../../test/fixtures/fake-strategies-store';
+
+export const createFakeExportImportTogglesService = (
+ config: IUnleashConfig,
+): ExportImportService => {
+ const { getLogger } = config;
+ const importTogglesStore = {} as ImportTogglesStore;
+ const featureToggleStore = new FakeFeatureToggleStore();
+ const tagStore = new FakeTagStore();
+ const tagTypeStore = new FakeTagTypeStore();
+ const segmentStore = new FakeSegmentStore();
+ const projectStore = new FakeProjectStore();
+ const featureTagStore = new FakeFeatureTagStore();
+ const strategyStore = new FakeStrategiesStore();
+ const contextFieldStore = new FakeContextFieldStore();
+ const eventStore = new FakeEventStore();
+ const featureStrategiesStore = new FakeFeatureStrategiesStore();
+ const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
+ const accessService = createFakeAccessService(config);
+ const featureToggleService = createFakeFeatureToggleService(config);
+
+ const featureTagService = new FeatureTagService(
+ {
+ tagStore,
+ featureTagStore,
+ eventStore,
+ featureToggleStore,
+ },
+ { getLogger },
+ );
+ const contextService = new ContextService(
+ {
+ projectStore,
+ eventStore,
+ contextFieldStore,
+ },
+ { getLogger },
+ );
+ const strategyService = new StrategyService(
+ { strategyStore, eventStore },
+ { getLogger },
+ );
+ const tagTypeService = new TagTypeService(
+ { tagTypeStore, eventStore },
+ { getLogger },
+ );
+ const exportImportService = new ExportImportService(
+ {
+ eventStore,
+ importTogglesStore,
+ featureStrategiesStore,
+ contextFieldStore,
+ featureToggleStore,
+ featureTagStore,
+ segmentStore,
+ tagTypeStore,
+ featureEnvironmentStore,
+ },
+ config,
+ {
+ featureToggleService,
+ featureTagService,
+ accessService,
+ contextService,
+ strategyService,
+ tagTypeService,
+ },
+ );
+
+ return exportImportService;
+};
+
+export const createExportImportTogglesService = (
+ db: Db,
+ config: IUnleashConfig,
+): ExportImportService => {
+ const { eventBus, getLogger, flagResolver } = config;
+ const importTogglesStore = new ImportTogglesStore(db);
+ const featureToggleStore = new FeatureToggleStore(db, eventBus, getLogger);
+ const tagStore = new TagStore(db, eventBus, getLogger);
+ const tagTypeStore = new TagTypeStore(db, eventBus, getLogger);
+ const segmentStore = new SegmentStore(db, eventBus, getLogger);
+ const projectStore = new ProjectStore(
+ db,
+ eventBus,
+ getLogger,
+ flagResolver,
+ );
+ const featureTagStore = new FeatureTagStore(db, eventBus, getLogger);
+ const strategyStore = new StrategyStore(db, getLogger);
+ const contextFieldStore = new ContextFieldStore(db, getLogger);
+ const eventStore = new EventStore(db, getLogger);
+ const featureStrategiesStore = new FeatureStrategiesStore(
+ db,
+ eventBus,
+ getLogger,
+ flagResolver,
+ );
+ const featureEnvironmentStore = new FeatureEnvironmentStore(
+ db,
+ eventBus,
+ getLogger,
+ );
+ const accessService = createAccessService(db, config);
+ const featureToggleService = createFeatureToggleService(db, config);
+
+ const featureTagService = new FeatureTagService(
+ {
+ tagStore,
+ featureTagStore,
+ eventStore,
+ featureToggleStore,
+ },
+ { getLogger },
+ );
+ const contextService = new ContextService(
+ {
+ projectStore,
+ eventStore,
+ contextFieldStore,
+ },
+ { getLogger },
+ );
+ const strategyService = new StrategyService(
+ { strategyStore, eventStore },
+ { getLogger },
+ );
+ const tagTypeService = new TagTypeService(
+ { tagTypeStore, eventStore },
+ { getLogger },
+ );
+ const exportImportService = new ExportImportService(
+ {
+ eventStore,
+ importTogglesStore,
+ featureStrategiesStore,
+ contextFieldStore,
+ featureToggleStore,
+ featureTagStore,
+ segmentStore,
+ tagTypeStore,
+ featureEnvironmentStore,
+ },
+ config,
+ {
+ featureToggleService,
+ featureTagService,
+ accessService,
+ contextService,
+ strategyService,
+ tagTypeService,
+ },
+ );
+
+ return exportImportService;
+};
diff --git a/src/lib/feature-toggle/index.ts b/src/lib/feature-toggle/index.ts
new file mode 100644
index 0000000000..5a3769a829
--- /dev/null
+++ b/src/lib/feature-toggle/index.ts
@@ -0,0 +1,155 @@
+import {
+ AccessService,
+ FeatureToggleService,
+ GroupService,
+ SegmentService,
+} from '../services';
+import EventStore from '../db/event-store';
+import FeatureStrategiesStore from '../db/feature-strategy-store';
+import FeatureToggleStore from '../db/feature-toggle-store';
+import FeatureToggleClientStore from '../db/feature-toggle-client-store';
+import ProjectStore from '../db/project-store';
+import FeatureTagStore from '../db/feature-tag-store';
+import { FeatureEnvironmentStore } from '../db/feature-environment-store';
+import SegmentStore from '../db/segment-store';
+import ContextFieldStore from '../db/context-field-store';
+import GroupStore from '../db/group-store';
+import { AccountStore } from '../db/account-store';
+import { AccessStore } from '../db/access-store';
+import RoleStore from '../db/role-store';
+import EnvironmentStore from '../db/environment-store';
+import { Db } from '../db/db';
+import { IUnleashConfig } from '../types';
+import FakeEventStore from '../../test/fixtures/fake-event-store';
+import FakeFeatureStrategiesStore from '../../test/fixtures/fake-feature-strategies-store';
+import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store';
+import FakeFeatureToggleClientStore from '../../test/fixtures/fake-feature-toggle-client-store';
+import FakeProjectStore from '../../test/fixtures/fake-project-store';
+import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
+import FakeFeatureEnvironmentStore from '../../test/fixtures/fake-feature-environment-store';
+import FakeSegmentStore from '../../test/fixtures/fake-segment-store';
+import FakeContextFieldStore from '../../test/fixtures/fake-context-field-store';
+import FakeGroupStore from '../../test/fixtures/fake-group-store';
+import { FakeAccountStore } from '../../test/fixtures/fake-account-store';
+import FakeAccessStore from '../../test/fixtures/fake-access-store';
+import FakeRoleStore from '../../test/fixtures/fake-role-store';
+import FakeEnvironmentStore from '../../test/fixtures/fake-environment-store';
+
+export const createFeatureToggleService = (
+ db: Db,
+ config: IUnleashConfig,
+): FeatureToggleService => {
+ const { getLogger, eventBus, flagResolver } = config;
+ const eventStore = new EventStore(db, getLogger);
+ const featureStrategiesStore = new FeatureStrategiesStore(
+ db,
+ eventBus,
+ getLogger,
+ flagResolver,
+ );
+ const featureToggleStore = new FeatureToggleStore(db, eventBus, getLogger);
+ const featureToggleClientStore = new FeatureToggleClientStore(
+ db,
+ eventBus,
+ getLogger,
+ config.inlineSegmentConstraints,
+ flagResolver,
+ );
+ const projectStore = new ProjectStore(
+ db,
+ eventBus,
+ getLogger,
+ flagResolver,
+ );
+ const featureTagStore = new FeatureTagStore(db, eventBus, getLogger);
+ const featureEnvironmentStore = new FeatureEnvironmentStore(
+ db,
+ eventBus,
+ getLogger,
+ );
+ const segmentStore = new SegmentStore(db, eventBus, getLogger);
+ const contextFieldStore = new ContextFieldStore(db, getLogger);
+ const groupStore = new GroupStore(db);
+ const accountStore = new AccountStore(db, getLogger);
+ const accessStore = new AccessStore(db, eventBus, getLogger);
+ const roleStore = new RoleStore(db, eventBus, getLogger);
+ const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
+ const groupService = new GroupService(
+ { groupStore, eventStore, accountStore },
+ { getLogger },
+ );
+ const accessService = new AccessService(
+ { accessStore, accountStore, roleStore, environmentStore },
+ { getLogger },
+ groupService,
+ );
+ const segmentService = new SegmentService(
+ { segmentStore, featureStrategiesStore, eventStore },
+ config,
+ );
+ const featureToggleService = new FeatureToggleService(
+ {
+ featureStrategiesStore,
+ featureToggleStore,
+ featureToggleClientStore,
+ projectStore,
+ eventStore,
+ featureTagStore,
+ featureEnvironmentStore,
+ contextFieldStore,
+ },
+ { getLogger, flagResolver },
+ segmentService,
+ accessService,
+ );
+ return featureToggleService;
+};
+
+export const createFakeFeatureToggleService = (
+ config: IUnleashConfig,
+): FeatureToggleService => {
+ const { getLogger, flagResolver } = config;
+ const eventStore = new FakeEventStore();
+ const featureStrategiesStore = new FakeFeatureStrategiesStore();
+ const featureToggleStore = new FakeFeatureToggleStore();
+ const featureToggleClientStore = new FakeFeatureToggleClientStore();
+ const projectStore = new FakeProjectStore();
+ const featureTagStore = new FakeFeatureTagStore();
+ const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
+ const segmentStore = new FakeSegmentStore();
+ const contextFieldStore = new FakeContextFieldStore();
+ const groupStore = new FakeGroupStore();
+ const accountStore = new FakeAccountStore();
+ const accessStore = new FakeAccessStore();
+ const roleStore = new FakeRoleStore();
+ const environmentStore = new FakeEnvironmentStore();
+ const groupService = new GroupService(
+ { groupStore, eventStore, accountStore },
+ { getLogger },
+ );
+ const accessService = new AccessService(
+ { accessStore, accountStore, roleStore, environmentStore },
+ { getLogger },
+ groupService,
+ );
+ const segmentService = new SegmentService(
+ { segmentStore, featureStrategiesStore, eventStore },
+ config,
+ );
+ const featureToggleService = new FeatureToggleService(
+ {
+ featureStrategiesStore,
+ featureToggleStore,
+ featureToggleClientStore,
+ projectStore,
+ eventStore,
+ featureTagStore,
+ featureEnvironmentStore,
+ contextFieldStore,
+ },
+ { getLogger, flagResolver },
+ segmentService,
+ accessService,
+ );
+ return featureToggleService;
+};
diff --git a/src/lib/internals.ts b/src/lib/internals.ts
index 985024012c..afd3c85967 100644
--- a/src/lib/internals.ts
+++ b/src/lib/internals.ts
@@ -13,3 +13,6 @@ export * from './services';
export * from './types';
export * from './util';
export * from './error';
+export * from './access';
+export * from './export-import-toggles';
+export * from './feature-toggle';
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index 24436bf6f2..713038d3b0 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -128,6 +128,9 @@ import {
variantsSchema,
versionSchema,
projectOverviewSchema,
+ importTogglesSchema,
+ importTogglesValidateSchema,
+ importTogglesValidateItemSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@@ -271,6 +274,9 @@ export const schemas = {
variantsSchema,
versionSchema,
projectOverviewSchema,
+ importTogglesSchema,
+ importTogglesValidateSchema,
+ importTogglesValidateItemSchema,
};
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
diff --git a/src/lib/openapi/spec/import-toggles-schema.ts b/src/lib/openapi/spec/import-toggles-schema.ts
new file mode 100644
index 0000000000..ca82dd8062
--- /dev/null
+++ b/src/lib/openapi/spec/import-toggles-schema.ts
@@ -0,0 +1,53 @@
+import { FromSchema } from 'json-schema-to-ts';
+import { exportResultSchema } from './export-result-schema';
+import { featureSchema } from './feature-schema';
+import { featureStrategySchema } from './feature-strategy-schema';
+import { contextFieldSchema } from './context-field-schema';
+import { featureTagSchema } from './feature-tag-schema';
+import { segmentSchema } from './segment-schema';
+import { variantsSchema } from './variants-schema';
+import { variantSchema } from './variant-schema';
+import { overrideSchema } from './override-schema';
+import { constraintSchema } from './constraint-schema';
+import { parametersSchema } from './parameters-schema';
+import { legalValueSchema } from './legal-value-schema';
+import { tagTypeSchema } from './tag-type-schema';
+import { featureEnvironmentSchema } from './feature-environment-schema';
+
+export const importTogglesSchema = {
+ $id: '#/components/schemas/importTogglesSchema',
+ type: 'object',
+ required: ['project', 'environment', 'data'],
+ additionalProperties: false,
+ properties: {
+ project: {
+ type: 'string',
+ },
+ environment: {
+ type: 'string',
+ },
+ data: {
+ $ref: '#/components/schemas/exportResultSchema',
+ },
+ },
+ components: {
+ schemas: {
+ exportResultSchema,
+ featureSchema,
+ featureStrategySchema,
+ featureEnvironmentSchema,
+ contextFieldSchema,
+ featureTagSchema,
+ segmentSchema,
+ variantsSchema,
+ variantSchema,
+ overrideSchema,
+ constraintSchema,
+ parametersSchema,
+ legalValueSchema,
+ tagTypeSchema,
+ },
+ },
+} as const;
+
+export type ImportTogglesSchema = FromSchema;
diff --git a/src/lib/openapi/spec/import-toggles-validate-item-schema.ts b/src/lib/openapi/spec/import-toggles-validate-item-schema.ts
new file mode 100644
index 0000000000..5b43cbea2a
--- /dev/null
+++ b/src/lib/openapi/spec/import-toggles-validate-item-schema.ts
@@ -0,0 +1,26 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const importTogglesValidateItemSchema = {
+ $id: '#/components/schemas/importTogglesValidateItemSchema',
+ type: 'object',
+ required: ['message', 'affectedItems'],
+ additionalProperties: false,
+ properties: {
+ message: {
+ type: 'string',
+ },
+ affectedItems: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ },
+ },
+ components: {
+ schemas: {},
+ },
+} as const;
+
+export type ImportTogglesValidateItemSchema = FromSchema<
+ typeof importTogglesValidateItemSchema
+>;
diff --git a/src/lib/openapi/spec/import-toggles-validate-schema.ts b/src/lib/openapi/spec/import-toggles-validate-schema.ts
new file mode 100644
index 0000000000..4b5883e70e
--- /dev/null
+++ b/src/lib/openapi/spec/import-toggles-validate-schema.ts
@@ -0,0 +1,38 @@
+import { FromSchema } from 'json-schema-to-ts';
+import { importTogglesValidateItemSchema } from './import-toggles-validate-item-schema';
+
+export const importTogglesValidateSchema = {
+ $id: '#/components/schemas/importTogglesValidateSchema',
+ type: 'object',
+ required: ['errors', 'warnings'],
+ additionalProperties: false,
+ properties: {
+ errors: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/importTogglesValidateItemSchema',
+ },
+ },
+ warnings: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/importTogglesValidateItemSchema',
+ },
+ },
+ permissions: {
+ type: 'array',
+ items: {
+ $ref: '#/components/schemas/importTogglesValidateItemSchema',
+ },
+ },
+ },
+ components: {
+ schemas: {
+ importTogglesValidateItemSchema,
+ },
+ },
+} as const;
+
+export type ImportTogglesValidateSchema = FromSchema<
+ typeof importTogglesValidateSchema
+>;
diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts
index 3b72892fa2..d08c8da12f 100644
--- a/src/lib/openapi/spec/index.ts
+++ b/src/lib/openapi/spec/index.ts
@@ -127,3 +127,6 @@ export * from './export-query-schema';
export * from './push-variants-schema';
export * from './project-stats-schema';
export * from './project-overview-schema';
+export * from './import-toggles-validate-item-schema';
+export * from './import-toggles-validate-schema';
+export * from './import-toggles-schema';
diff --git a/src/lib/routes/admin-api/export-import.ts b/src/lib/routes/admin-api/export-import.ts
deleted file mode 100644
index 08692f9f9f..0000000000
--- a/src/lib/routes/admin-api/export-import.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Response } from 'express';
-import Controller from '../controller';
-import { NONE } from '../../types/permissions';
-import { IUnleashConfig } from '../../types/option';
-import { IUnleashServices } from '../../types/services';
-import { Logger } from '../../logger';
-import { OpenApiService } from '../../services/openapi-service';
-import ExportImportService from 'lib/services/export-import-service';
-import { InvalidOperationError } from '../../error';
-import { createRequestSchema, createResponseSchema } from '../../openapi';
-import { exportResultSchema } from '../../openapi/spec/export-result-schema';
-import { ExportQuerySchema } from '../../openapi/spec/export-query-schema';
-import { serializeDates } from '../../types';
-import { IAuthRequest } from '../unleash-types';
-import { format as formatDate } from 'date-fns';
-import { extractUsername } from '../../util';
-
-class ExportImportController extends Controller {
- private logger: Logger;
-
- private exportImportService: ExportImportService;
-
- private openApiService: OpenApiService;
-
- constructor(
- config: IUnleashConfig,
- {
- exportImportService,
- openApiService,
- }: Pick,
- ) {
- super(config);
- this.logger = config.getLogger('/admin-api/export-import.ts');
- this.exportImportService = exportImportService;
- this.openApiService = openApiService;
- this.route({
- method: 'post',
- path: '/export',
- permission: NONE,
- handler: this.export,
- middleware: [
- this.openApiService.validPath({
- tags: ['Unstable'],
- operationId: 'exportFeatures',
- requestBody: createRequestSchema('exportQuerySchema'),
- responses: {
- 200: createResponseSchema('exportResultSchema'),
- },
- }),
- ],
- });
- }
-
- async export(
- req: IAuthRequest,
- res: Response,
- ): Promise {
- this.verifyExportImportEnabled();
- const query = req.body;
- const userName = extractUsername(req);
- const data = await this.exportImportService.export(query, userName);
-
- this.openApiService.respondWithValidation(
- 200,
- res,
- exportResultSchema.$id,
- serializeDates(data),
- );
- }
-
- private getFormattedDate(millis: number): string {
- return formatDate(millis, 'yyyy-MM-dd_HH-mm-ss');
- }
-
- private verifyExportImportEnabled() {
- if (!this.config.flagResolver.isEnabled('featuresExportImport')) {
- throw new InvalidOperationError(
- 'Feature export/import is not enabled',
- );
- }
- }
-}
-export default ExportImportController;
diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts
index f272c7c2f3..6bffa9c8e3 100644
--- a/src/lib/routes/admin-api/index.ts
+++ b/src/lib/routes/admin-api/index.ts
@@ -28,10 +28,12 @@ import { PublicSignupController } from './public-signup';
import InstanceAdminController from './instance-admin';
import FavoritesController from './favorites';
import MaintenanceController from './maintenance';
-import ExportImportController from './export-import';
+import { createKnexTransactionStarter } from '../../db/transaction';
+import { Db } from '../../db/db';
+import ExportImportController from '../../export-import-toggles/export-import-controller';
class AdminApi extends Controller {
- constructor(config: IUnleashConfig, services: IUnleashServices) {
+ constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
super(config);
this.app.use(
@@ -80,7 +82,11 @@ class AdminApi extends Controller {
this.app.use('/state', new StateController(config, services).router);
this.app.use(
'/features-batch',
- new ExportImportController(config, services).router,
+ new ExportImportController(
+ config,
+ services,
+ createKnexTransactionStarter(db),
+ ).router,
);
this.app.use('/tags', new TagController(config, services).router);
this.app.use(
diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts
index 276946608a..3cee6a9805 100644
--- a/src/lib/routes/index.ts
+++ b/src/lib/routes/index.ts
@@ -13,9 +13,10 @@ import ProxyController from './proxy-api';
import { conditionalMiddleware } from '../middleware';
import EdgeController from './edge-api';
import { PublicInviteController } from './public-invite';
+import { Db } from '../db/db';
class IndexRouter extends Controller {
- constructor(config: IUnleashConfig, services: IUnleashServices) {
+ constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
super(config);
this.use('/health', new HealthCheckController(config, services).router);
@@ -40,7 +41,7 @@ class IndexRouter extends Controller {
new ResetPasswordController(config, services).router,
);
- this.use('/api/admin', new AdminApi(config, services).router);
+ this.use('/api/admin', new AdminApi(config, services, db).router);
this.use('/api/client', new ClientApi(config, services).router);
this.use(
diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts
index 7e3b7d903d..d67fd3b20c 100644
--- a/src/lib/server-impl.ts
+++ b/src/lib/server-impl.ts
@@ -42,7 +42,7 @@ async function createApp(
const serverVersion = version;
const db = createDb(config);
const stores = createStores(config, db);
- const services = createServices(stores, config);
+ const services = createServices(stores, config, db);
scheduleServices(services, config);
const metricsMonitor = createMetricsMonitor();
diff --git a/src/lib/services/export-import-service.ts b/src/lib/services/export-import-service.ts
deleted file mode 100644
index 02f0c85713..0000000000
--- a/src/lib/services/export-import-service.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import { IUnleashConfig } from '../types/option';
-import { IFeatureStrategy, IFeatureStrategySegment } from '../types/model';
-import { Logger } from '../logger';
-import { IFeatureTagStore } from '../types/stores/feature-tag-store';
-import { IProjectStore } from '../types/stores/project-store';
-import { ITagTypeStore } from '../types/stores/tag-type-store';
-import { ITagStore } from '../types/stores/tag-store';
-import { IEventStore } from '../types/stores/event-store';
-import { IStrategyStore } from '../types/stores/strategy-store';
-import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
-import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
-import { IEnvironmentStore } from '../types/stores/environment-store';
-import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
-import { IContextFieldStore, IUnleashStores } from '../types/stores';
-import { ISegmentStore } from '../types/stores/segment-store';
-import FeatureToggleService from './feature-toggle-service';
-import { ExportQuerySchema } from '../openapi/spec/export-query-schema';
-import { FEATURES_EXPORTED, IFlagResolver, IUnleashServices } from '../types';
-import { ExportResultSchema } from '../openapi';
-
-export default class ExportImportService {
- private logger: Logger;
-
- private toggleStore: IFeatureToggleStore;
-
- private featureStrategiesStore: IFeatureStrategiesStore;
-
- private strategyStore: IStrategyStore;
-
- private eventStore: IEventStore;
-
- private tagStore: ITagStore;
-
- private tagTypeStore: ITagTypeStore;
-
- private projectStore: IProjectStore;
-
- private featureEnvironmentStore: IFeatureEnvironmentStore;
-
- private featureTagStore: IFeatureTagStore;
-
- private environmentStore: IEnvironmentStore;
-
- private segmentStore: ISegmentStore;
-
- private flagResolver: IFlagResolver;
-
- private featureToggleService: FeatureToggleService;
-
- private contextFieldStore: IContextFieldStore;
-
- constructor(
- stores: IUnleashStores,
- {
- getLogger,
- flagResolver,
- }: Pick,
- {
- featureToggleService,
- }: Pick,
- ) {
- this.eventStore = stores.eventStore;
- this.toggleStore = stores.featureToggleStore;
- this.strategyStore = stores.strategyStore;
- this.tagStore = stores.tagStore;
- this.featureStrategiesStore = stores.featureStrategiesStore;
- this.featureEnvironmentStore = stores.featureEnvironmentStore;
- this.tagTypeStore = stores.tagTypeStore;
- this.projectStore = stores.projectStore;
- this.featureTagStore = stores.featureTagStore;
- this.environmentStore = stores.environmentStore;
- this.segmentStore = stores.segmentStore;
- this.flagResolver = flagResolver;
- this.featureToggleService = featureToggleService;
- this.contextFieldStore = stores.contextFieldStore;
- this.logger = getLogger('services/state-service.js');
- }
-
- async export(
- query: ExportQuerySchema,
- userName: string,
- ): Promise {
- const [
- features,
- featureEnvironments,
- featureStrategies,
- strategySegments,
- contextFields,
- featureTags,
- segments,
- tagTypes,
- ] = await Promise.all([
- this.toggleStore.getAllByNames(query.features),
- await this.featureEnvironmentStore.getAllByFeatures(
- query.features,
- query.environment,
- ),
- this.featureStrategiesStore.getAllByFeatures(
- query.features,
- query.environment,
- ),
- this.segmentStore.getAllFeatureStrategySegments(),
- this.contextFieldStore.getAll(),
- this.featureTagStore.getAllByFeatures(query.features),
- this.segmentStore.getAll(),
- this.tagTypeStore.getAll(),
- ]);
- 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,
- ),
- ),
- );
- 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 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,
- ...rest
- } = item;
- return {
- name,
- ...rest,
- };
- }),
- featureEnvironments: featureEnvironments.map((item) => ({
- ...item,
- 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,
- };
- await this.eventStore.store({
- type: FEATURES_EXPORTED,
- createdBy: userName,
- data: result,
- });
-
- return result;
- }
-
- addSegmentsToStrategies(
- featureStrategies: IFeatureStrategy[],
- strategySegments: IFeatureStrategySegment[],
- ): void {
- featureStrategies.forEach((featureStrategy) => {
- featureStrategy.segments = strategySegments
- .filter(
- (segment) =>
- segment.featureStrategyId === featureStrategy.id,
- )
- .map((segment) => segment.segmentId);
- });
- }
-}
-
-module.exports = ExportImportService;
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index b368641625..7e337122c3 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -39,10 +39,15 @@ import { LastSeenService } from './client-metrics/last-seen-service';
import { InstanceStatsService } from './instance-stats-service';
import { FavoritesService } from './favorites-service';
import MaintenanceService from './maintenance-service';
-import ExportImportService from './export-import-service';
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
import { AccountService } from './account-service';
import { SchedulerService } from './scheduler-service';
+import { Knex } from 'knex';
+import {
+ createExportImportTogglesService,
+ createFakeExportImportTogglesService,
+} from '../export-import-toggles';
+import { Db } from '../db/db';
// TODO: will be moved to scheduler feature directory
export const scheduleServices = (
@@ -96,6 +101,7 @@ export const scheduleServices = (
export const createServices = (
stores: IUnleashStores,
config: IUnleashConfig,
+ db?: Db,
): IUnleashServices => {
const groupService = new GroupService(stores, config);
const accessService = new AccessService(stores, config, groupService);
@@ -139,9 +145,6 @@ export const createServices = (
segmentService,
accessService,
);
- const exportImportService = new ExportImportService(stores, config, {
- featureToggleService: featureToggleServiceV2,
- });
const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config);
const favoritesService = new FavoritesService(stores, config);
@@ -158,6 +161,11 @@ export const createServices = (
config,
projectService,
);
+ const exportImportService = db
+ ? createExportImportTogglesService(db, config)
+ : createFakeExportImportTogglesService(config);
+ const transactionalExportImportService = (txDb: Knex.Transaction) =>
+ createExportImportTogglesService(txDb, config);
const userSplashService = new UserSplashService(stores, config);
const openApiService = new OpenApiService(config);
const clientSpecService = new ClientSpecService(config);
@@ -239,6 +247,7 @@ export const createServices = (
favoritesService,
maintenanceService,
exportImportService,
+ transactionalExportImportService,
schedulerService,
};
};
@@ -282,6 +291,5 @@ export {
LastSeenService,
InstanceStatsService,
FavoritesService,
- ExportImportService,
SchedulerService,
};
diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts
index cd7a31a16f..79944d3fce 100644
--- a/src/lib/types/services.ts
+++ b/src/lib/types/services.ts
@@ -37,9 +37,10 @@ import { LastSeenService } from '../services/client-metrics/last-seen-service';
import { InstanceStatsService } from '../services/instance-stats-service';
import { FavoritesService } from '../services/favorites-service';
import MaintenanceService from '../services/maintenance-service';
-import ExportImportService from 'lib/services/export-import-service';
+import ExportImportService from '../export-import-toggles/export-import-service';
import { AccountService } from '../services/account-service';
import { SchedulerService } from '../services/scheduler-service';
+import { Knex } from 'knex';
export interface IUnleashServices {
accessService: AccessService;
@@ -85,4 +86,7 @@ export interface IUnleashServices {
maintenanceService: MaintenanceService;
exportImportService: ExportImportService;
schedulerService: SchedulerService;
+ transactionalExportImportService: (
+ db: Knex.Transaction,
+ ) => ExportImportService;
}
diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts
index 20e01a39eb..61b27232fb 100644
--- a/src/lib/types/stores.ts
+++ b/src/lib/types/stores.ts
@@ -32,6 +32,7 @@ import { IFavoriteFeaturesStore } from './stores/favorite-features';
import { IFavoriteProjectsStore } from './stores/favorite-projects';
import { IAccountStore } from './stores/account-store';
import { IProjectStatsStore } from './stores/project-stats-store-type';
+import { IImportTogglesStore } from '../export-import-toggles/import-toggles-store-type';
export interface IUnleashStores {
accessStore: IAccessStore;
@@ -68,6 +69,7 @@ export interface IUnleashStores {
favoriteFeaturesStore: IFavoriteFeaturesStore;
favoriteProjectsStore: IFavoriteProjectsStore;
projectStatsStore: IProjectStatsStore;
+ importTogglesStore: IImportTogglesStore;
}
export {
@@ -104,4 +106,5 @@ export {
IUserStore,
IFavoriteFeaturesStore,
IFavoriteProjectsStore,
+ IImportTogglesStore,
};
diff --git a/src/test/e2e/api/admin/export-import.e2e.test.ts b/src/test/e2e/api/admin/export-import.e2e.test.ts
index c4d63b7c92..578994acd3 100644
--- a/src/test/e2e/api/admin/export-import.e2e.test.ts
+++ b/src/test/e2e/api/admin/export-import.e2e.test.ts
@@ -15,8 +15,12 @@ import {
IVariant,
} from 'lib/types';
import { DEFAULT_ENV } from '../../../../lib/util';
-import { ContextFieldSchema } from '../../../../lib/openapi';
+import {
+ ContextFieldSchema,
+ ImportTogglesSchema,
+} from '../../../../lib/openapi';
import User from '../../../../lib/types/user';
+import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store';
let app: IUnleashTest;
let db: ITestDb;
@@ -120,15 +124,50 @@ const createSegment = (postData: object): Promise => {
});
};
+const createContextField = async (contextField: IContextFieldDto) => {
+ await app.request.post(`/api/admin/context`).send(contextField).expect(201);
+};
+
+const createFeature = async (featureName: string, project: string) => {
+ await app.request
+ .post(`/api/admin/projects/${project}/features`)
+ .send({
+ name: featureName,
+ })
+ .set('Content-Type', 'application/json')
+ .expect(201);
+};
+
+const archiveFeature = async (featureName: string, project: string) => {
+ await app.request
+ .delete(`/api/admin/projects/${project}/features/${featureName}`)
+ .set('Content-Type', 'application/json')
+ .expect(202);
+};
+
+const unArchiveFeature = async (featureName: string) => {
+ await app.request
+ .post(`/api/admin/archive/revive/${featureName}`)
+ .set('Content-Type', 'application/json')
+ .expect(200);
+};
+
+const getContextField = (name: string) =>
+ app.request.get(`/api/admin/context/${name}`).expect(200);
+
beforeAll(async () => {
db = await dbInit('export_import_api_serial', getLogger);
- app = await setupAppWithCustomConfig(db.stores, {
- experimental: {
- flags: {
- featuresExportImport: true,
+ app = await setupAppWithCustomConfig(
+ db.stores,
+ {
+ experimental: {
+ flags: {
+ featuresExportImport: true,
+ },
},
},
- });
+ db.rawDatabase,
+ );
eventStore = db.stores.eventStore;
environmentStore = db.stores.environmentStore;
projectStore = db.stores.projectStore;
@@ -371,3 +410,371 @@ test('returns no features, when no feature was requested', async () => {
expect(body.features).toHaveLength(0);
});
+
+const importToggles = (
+ importPayload: ImportTogglesSchema,
+ status = 200,
+ expect: (response) => void = () => {},
+) =>
+ app.request
+ .post('/api/admin/features-batch/full-import')
+ .send(importPayload)
+ .set('Content-Type', 'application/json')
+ .expect(status)
+ .expect(expect);
+
+const defaultFeature = 'first_feature';
+const defaultProject = 'default';
+const defaultEnvironment = 'defalt';
+
+const variants: ImportTogglesSchema['data']['featureEnvironments'][0]['variants'] =
+ [
+ {
+ name: 'variantA',
+ weight: 500,
+ payload: {
+ type: 'string',
+ value: 'payloadA',
+ },
+ overrides: [],
+ stickiness: 'default',
+ weightType: 'variable',
+ },
+ {
+ name: 'variantB',
+ weight: 500,
+ payload: {
+ type: 'string',
+ value: 'payloadB',
+ },
+ overrides: [],
+ stickiness: 'default',
+ weightType: 'variable',
+ },
+ ];
+const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
+ project: 'old_project',
+ name: 'first_feature',
+};
+const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constraints'] =
+ [
+ {
+ values: ['conduit'],
+ inverted: false,
+ operator: 'IN',
+ contextName: 'appName',
+ caseInsensitive: false,
+ },
+ ];
+const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
+ featureName: defaultFeature,
+ id: '798cb25a-2abd-47bd-8a95-40ec13472309',
+ name: 'default',
+ parameters: {},
+ constraints,
+};
+
+const tags = [
+ {
+ featureName: defaultFeature,
+ tagType: 'simple',
+ tagValue: 'tag1',
+ },
+ {
+ featureName: defaultFeature,
+ tagType: 'simple',
+ tagValue: 'tag2',
+ },
+ {
+ featureName: defaultFeature,
+ tagType: 'special_tag',
+ tagValue: 'feature_tagged',
+ },
+];
+
+const resultTags = [
+ { 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
+];
+
+const defaultImportPayload: ImportTogglesSchema = {
+ data: {
+ features: [exportedFeature],
+ featureStrategies: [exportedStrategy],
+ featureEnvironments: [
+ {
+ enabled: true,
+ environment: 'irrelevant',
+ featureName: defaultFeature,
+ name: defaultFeature,
+ variants,
+ },
+ ],
+ featureTags: tags,
+ tagTypes,
+ contextFields: [],
+ segments: [],
+ },
+ project: defaultProject,
+ environment: defaultEnvironment,
+};
+
+const getFeature = async (feature: string) =>
+ app.request.get(`/api/admin/features/${feature}`).expect(200);
+
+const getFeatureEnvironment = (
+ project: string,
+ feature: string,
+ environment: string,
+) =>
+ app.request
+ .get(
+ `/api/admin/projects/${project}/features/${feature}/environments/${environment}`,
+ )
+ .expect(200);
+
+const getTags = (feature: string) =>
+ app.request.get(`/api/admin/features/${feature}/tags`).expect(200);
+
+const validateImport = (importPayload: ImportTogglesSchema, status = 200) =>
+ app.request
+ .post('/api/admin/features-batch/full-validate')
+ .send(importPayload)
+ .set('Content-Type', 'application/json')
+ .expect(status);
+
+test('import features to existing project and environment', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+
+ await importToggles(defaultImportPayload);
+
+ const { body: importedFeature } = await getFeature(defaultFeature);
+ expect(importedFeature).toMatchObject({
+ name: 'first_feature',
+ project: defaultProject,
+ variants,
+ });
+
+ const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
+ defaultProject,
+ defaultFeature,
+ defaultEnvironment,
+ );
+ expect(importedFeatureEnvironment).toMatchObject({
+ name: defaultFeature,
+ environment: defaultEnvironment,
+ enabled: true,
+ strategies: [
+ {
+ featureName: defaultFeature,
+ parameters: {},
+ constraints,
+ sortOrder: 9999,
+ name: 'default',
+ },
+ ],
+ });
+
+ const { body: importedTags } = await getTags(defaultFeature);
+ expect(importedTags).toMatchObject({
+ tags: resultTags,
+ });
+});
+
+test('importing same JSON should work multiple times in a row', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ await importToggles(defaultImportPayload);
+ await importToggles(defaultImportPayload);
+
+ const { body: importedFeature } = await getFeature(defaultFeature);
+ expect(importedFeature).toMatchObject({
+ name: 'first_feature',
+ project: defaultProject,
+ variants,
+ });
+
+ const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
+ defaultProject,
+ defaultFeature,
+ defaultEnvironment,
+ );
+
+ expect(importedFeatureEnvironment).toMatchObject({
+ name: defaultFeature,
+ environment: defaultEnvironment,
+ enabled: true,
+ strategies: [
+ {
+ featureName: defaultFeature,
+ parameters: {},
+ constraints,
+ sortOrder: 9999,
+ name: 'default',
+ },
+ ],
+ });
+});
+
+test('reject import with unknown context fields', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ const contextField = {
+ name: 'ContextField1',
+ legalValues: [{ value: 'Value1', description: '' }],
+ };
+ await createContextField(contextField);
+ const importPayloadWithContextFields: ImportTogglesSchema = {
+ ...defaultImportPayload,
+ data: {
+ ...defaultImportPayload.data,
+ contextFields: [
+ {
+ ...contextField,
+ legalValues: [{ value: 'Value2', description: '' }],
+ },
+ ],
+ },
+ };
+
+ const { body } = await importToggles(importPayloadWithContextFields, 400);
+
+ expect(body).toMatchObject({
+ details: [
+ {
+ message: 'Context fields with errors: ContextField1',
+ },
+ ],
+ });
+});
+
+test('reject import with unsupported strategies', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ const importPayloadWithContextFields: ImportTogglesSchema = {
+ ...defaultImportPayload,
+ data: {
+ ...defaultImportPayload.data,
+ featureStrategies: [{ name: 'customStrategy' }],
+ },
+ };
+
+ const { body } = await importToggles(importPayloadWithContextFields, 400);
+
+ expect(body).toMatchObject({
+ details: [
+ {
+ message: 'Unsupported strategies: customStrategy',
+ },
+ ],
+ });
+});
+
+test('validate import data', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ const contextField: IContextFieldDto = {
+ name: 'validate_context_field',
+ legalValues: [{ value: 'Value1' }],
+ };
+
+ const createdContextField: IContextFieldDto = {
+ name: 'created_context_field',
+ legalValues: [{ value: 'new_value' }],
+ };
+
+ await createFeature(defaultFeature, defaultProject);
+ await archiveFeature(defaultFeature, defaultProject);
+
+ await createContextField(contextField);
+ const importPayloadWithContextFields: ImportTogglesSchema = {
+ ...defaultImportPayload,
+ data: {
+ ...defaultImportPayload.data,
+ featureStrategies: [{ name: 'customStrategy' }],
+ segments: [{ id: 1, name: 'customSegment' }],
+ contextFields: [
+ {
+ ...contextField,
+ legalValues: [{ value: 'Value2' }],
+ },
+ createdContextField,
+ ],
+ },
+ };
+
+ const { body } = await validateImport(importPayloadWithContextFields, 200);
+
+ expect(body).toMatchObject({
+ errors: [
+ {
+ message:
+ 'We detected the following custom strategy in the import file that needs to be created first:',
+ affectedItems: ['customStrategy'],
+ },
+ {
+ message:
+ 'We detected the following context fields that do not have matching legal values with the imported ones:',
+ affectedItems: [contextField.name],
+ },
+ ],
+ warnings: [
+ {
+ message:
+ 'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
+ affectedItems: [defaultFeature],
+ },
+ ],
+ permissions: [],
+ });
+});
+
+test('should create new context', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ const context = {
+ name: 'create-new-context',
+ legalValues: [{ value: 'Value1' }],
+ };
+ const importPayloadWithContextFields: ImportTogglesSchema = {
+ ...defaultImportPayload,
+ data: {
+ ...defaultImportPayload.data,
+ contextFields: [context],
+ },
+ };
+
+ await importToggles(importPayloadWithContextFields, 200);
+
+ const { body } = await getContextField(context.name);
+ expect(body).toMatchObject(context);
+});
+
+test('should not import archived features tags', async () => {
+ await createProject(defaultProject, defaultEnvironment);
+ await importToggles(defaultImportPayload);
+
+ await archiveFeature(defaultFeature, defaultProject);
+
+ await importToggles({
+ ...defaultImportPayload,
+ data: {
+ ...defaultImportPayload.data,
+ featureTags: [
+ {
+ featureName: defaultFeature,
+ tagType: 'simple',
+ tagValue: 'tag2',
+ },
+ ],
+ },
+ });
+ await unArchiveFeature(defaultFeature);
+
+ const { body: importedTags } = await getTags(defaultFeature);
+ expect(importedTags).toMatchObject({
+ tags: resultTags,
+ });
+});
diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
index 4bb2ce059b..3a1e50e5a6 100644
--- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
+++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
@@ -1874,6 +1874,73 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
+ "importTogglesSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/exportResultSchema",
+ },
+ "environment": {
+ "type": "string",
+ },
+ "project": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "project",
+ "environment",
+ "data",
+ ],
+ "type": "object",
+ },
+ "importTogglesValidateItemSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "affectedItems": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "message": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "message",
+ "affectedItems",
+ ],
+ "type": "object",
+ },
+ "importTogglesValidateSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "errors": {
+ "items": {
+ "$ref": "#/components/schemas/importTogglesValidateItemSchema",
+ },
+ "type": "array",
+ },
+ "permissions": {
+ "items": {
+ "$ref": "#/components/schemas/importTogglesValidateItemSchema",
+ },
+ "type": "array",
+ },
+ "warnings": {
+ "items": {
+ "$ref": "#/components/schemas/importTogglesValidateItemSchema",
+ },
+ "type": "array",
+ },
+ },
+ "required": [
+ "errors",
+ "warnings",
+ ],
+ "type": "object",
+ },
"instanceAdminStatsSchema": {
"additionalProperties": false,
"properties": {
@@ -5062,6 +5129,65 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
+ "/api/admin/features-batch/full-import": {
+ "post": {
+ "description": "Unleash toggles exported from a different instance can be imported into a new project and environment",
+ "operationId": "importToggles",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/importTogglesSchema",
+ },
+ },
+ },
+ "description": "importTogglesSchema",
+ "required": true,
+ },
+ "responses": {
+ "200": {
+ "description": "This response has no body.",
+ },
+ },
+ "summary": "Import feature toggles for an environment in the project",
+ "tags": [
+ "Unstable",
+ ],
+ },
+ },
+ "/api/admin/features-batch/full-validate": {
+ "post": {
+ "description": "Unleash toggles exported from a different instance can be imported into a new project and environment",
+ "operationId": "validateImport",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/importTogglesSchema",
+ },
+ },
+ },
+ "description": "importTogglesSchema",
+ "required": true,
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/importTogglesValidateSchema",
+ },
+ },
+ },
+ "description": "importTogglesValidateSchema",
+ },
+ },
+ "summary": "Validate import of feature toggles for an environment in the project",
+ "tags": [
+ "Unstable",
+ ],
+ },
+ },
"/api/admin/features/validate": {
"post": {
"operationId": "validateFeature",
diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts
index 65fc875f76..69ea5d0d54 100644
--- a/src/test/e2e/helpers/test-helper.ts
+++ b/src/test/e2e/helpers/test-helper.ts
@@ -9,6 +9,7 @@ import { createServices } from '../../../lib/services';
import sessionDb from '../../../lib/middleware/session-db';
import { IUnleashStores } from '../../../lib/types';
import { IUnleashServices } from '../../../lib/types/services';
+import { Db } from '../../../lib/db/db';
process.env.NODE_ENV = 'test';
@@ -24,6 +25,7 @@ async function createApp(
adminAuthentication = IAuthType.NONE,
preHook?: Function,
customOptions?: any,
+ db?: Db,
): Promise {
const config = createTestConfig({
authentication: {
@@ -35,11 +37,11 @@ async function createApp(
},
...customOptions,
});
- const services = createServices(stores, config);
+ const services = createServices(stores, config, db);
const unleashSession = sessionDb(config, undefined);
const emitter = new EventEmitter();
emitter.setMaxListeners(0);
- const app = await getApp(config, stores, services, unleashSession);
+ const app = await getApp(config, stores, services, unleashSession, db);
const request = supertest.agent(app);
const destroy = async () => {
@@ -60,8 +62,9 @@ export async function setupApp(stores: IUnleashStores): Promise {
export async function setupAppWithCustomConfig(
stores: IUnleashStores,
customOptions: any,
+ db?: Db,
): Promise {
- return createApp(stores, undefined, undefined, customOptions);
+ return createApp(stores, undefined, undefined, customOptions, db);
}
export async function setupAppWithAuth(
diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts
index 58f599b658..5e0bccd225 100644
--- a/src/test/fixtures/store.ts
+++ b/src/test/fixtures/store.ts
@@ -15,7 +15,7 @@ import FakeUserFeedbackStore from './fake-user-feedback-store';
import FakeFeatureTagStore from './fake-feature-tag-store';
import FakeEnvironmentStore from './fake-environment-store';
import FakeStrategiesStore from './fake-strategies-store';
-import { IUnleashStores } from '../../lib/types';
+import { IImportTogglesStore, IUnleashStores } from '../../lib/types';
import FakeSessionStore from './fake-session-store';
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
import FakeApiTokenStore from './fake-api-token-store';
@@ -34,13 +34,13 @@ import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
import { FakeAccountStore } from './fake-account-store';
import FakeProjectStatsStore from './fake-project-stats-store';
-const createStores: () => IUnleashStores = () => {
- const db = {
- select: () => ({
- from: () => Promise.resolve(),
- }),
- };
+const db = {
+ select: () => ({
+ from: () => Promise.resolve(),
+ }),
+};
+const createStores: () => IUnleashStores = () => {
return {
db,
clientApplicationsStore: new FakeClientApplicationsStore(),
@@ -77,6 +77,7 @@ const createStores: () => IUnleashStores = () => {
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
projectStatsStore: new FakeProjectStatsStore(),
+ importTogglesStore: {} as IImportTogglesStore,
};
};