1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +02:00

feat: oss import (#3123)

This commit is contained in:
Mateusz Kwasniewski 2023-02-16 08:08:51 +01:00 committed by GitHub
parent 0e7eca059b
commit f0c9f8b08b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 2231 additions and 340 deletions

View File

@ -145,11 +145,6 @@ export const Project = () => {
<PermissionIconButton
permission={CREATE_FEATURE}
projectId={projectId}
sx={{
visibility: isOss()
? 'hidden'
: 'visible',
}}
onClick={() => setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
@ -159,34 +154,42 @@ export const Project = () => {
</PermissionIconButton>
}
/>
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
sx={{
visibility: isOss() ? 'hidden' : 'visible',
}}
onClick={() =>
navigate(`/projects/${projectId}/edit`)
<ConditionallyRender
condition={!isOss()}
show={
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
onClick={() =>
navigate(
`/projects/${projectId}/edit`
)
}
tooltipProps={{ title: 'Edit project' }}
data-loading
>
<Edit />
</PermissionIconButton>
}
tooltipProps={{ title: 'Edit project' }}
data-loading
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_PROJECT}
projectId={projectId}
sx={{
visibility: isOss() ? 'hidden' : 'visible',
}}
onClick={() => {
setShowDelDialog(true);
}}
tooltipProps={{ title: 'Delete project' }}
data-loading
>
<Delete />
</PermissionIconButton>
/>
<ConditionallyRender
condition={!isOss()}
show={
<PermissionIconButton
permission={DELETE_PROJECT}
projectId={projectId}
onClick={() => {
setShowDelDialog(true);
}}
tooltipProps={{
title: 'Delete project',
}}
data-loading
>
<Delete />
</PermissionIconButton>
}
/>
</StyledDiv>
</StyledTopRow>
<ConditionallyRender

59
src/lib/access/index.ts Normal file
View File

@ -0,0 +1,59 @@
import { Db, IUnleashConfig } from 'lib/server-impl';
import EventStore from '../db/event-store';
import GroupStore from '../db/group-store';
import { AccountStore } from '../db/account-store';
import RoleStore from '../db/role-store';
import EnvironmentStore from '../db/environment-store';
import { AccessStore } from '../db/access-store';
import { AccessService, GroupService } from '../services';
import FakeEventStore from '../../test/fixtures/fake-event-store';
import FakeGroupStore from '../../test/fixtures/fake-group-store';
import { FakeAccountStore } from '../../test/fixtures/fake-account-store';
import FakeRoleStore from '../../test/fixtures/fake-role-store';
import FakeEnvironmentStore from '../../test/fixtures/fake-environment-store';
import FakeAccessStore from '../../test/fixtures/fake-access-store';
export const createAccessService = (
db: Db,
config: IUnleashConfig,
): AccessService => {
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,
);
};

View File

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

View File

@ -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),
};
};

22
src/lib/db/transaction.ts Normal file
View File

@ -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<S> = <T>(
scope: (trx: S) => void | Promise<T>,
) => Promise<T>;
export const createKnexTransactionStarter = (
knex: Knex,
): TransactionCreator<UnleashTransaction> => {
function transaction<T>(
scope: (trx: KnexTransaction) => void | Promise<T>,
) {
return knex.transaction(scope);
}
return transaction;
};

View File

@ -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<UnleashTransaction>;
constructor(
config: IUnleashConfig,
{
exportImportService,
transactionalExportImportService,
openApiService,
}: Pick<
IUnleashServices,
| 'exportImportService'
| 'openApiService'
| 'transactionalExportImportService'
>,
startTransaction: TransactionCreator<UnleashTransaction>,
) {
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<unknown, unknown, ExportQuerySchema, unknown>,
res: Response,
): Promise<void> {
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<unknown, unknown, ImportTogglesSchema, unknown>,
res: Response,
): Promise<void> {
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<unknown, unknown, ImportTogglesSchema, unknown>,
res: Response,
): Promise<void> {
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;

View File

@ -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<IUnleashConfig, 'getLogger' | 'flagResolver'>,
{
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<ImportTogglesValidateSchema> {
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<void> {
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<FeatureStrategySchema[]> {
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<string[]> {
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<ExportResultSchema> {
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;

View File

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

View File

@ -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),
);
};

View File

@ -0,0 +1,19 @@
export interface IImportTogglesStore {
deleteStrategiesForFeatures(
featureNames: string[],
environment: string,
): Promise<void>;
getArchivedFeatures(featureNames: string[]): Promise<string[]>;
getFeaturesInOtherProjects(
featureNames: string[],
project: string,
): Promise<{ name: string; project: string }[]>;
deleteTagsForFeatures(tags: string[]): Promise<void>;
getDisplayPermissions(
names: string[],
): Promise<{ name: string; displayName: string }[]>;
}

View File

@ -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<void> {
return this.db(T.featureStrategies)
.where({ environment })
.whereIn('feature_name', featureNames)
.del();
}
async getArchivedFeatures(featureNames: string[]): Promise<string[]> {
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<void> {
return this.db(T.feature_tag).whereIn('feature_name', tags).del();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IUnleashServices, 'exportImportService' | 'openApiService'>,
) {
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<unknown, unknown, ExportQuerySchema, unknown>,
res: Response,
): Promise<void> {
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;

View File

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

View File

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

View File

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

View File

@ -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<IUnleashConfig, 'getLogger' | 'flagResolver'>,
{
featureToggleService,
}: Pick<IUnleashServices, 'featureToggleService'>,
) {
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<ExportResultSchema> {
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;

View File

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

View File

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

View File

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

View File

@ -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<ISegment> => {
});
};
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,
});
});

View File

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

View File

@ -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<IUnleashTest> {
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<IUnleashTest> {
export async function setupAppWithCustomConfig(
stores: IUnleashStores,
customOptions: any,
db?: Db,
): Promise<IUnleashTest> {
return createApp(stores, undefined, undefined, customOptions);
return createApp(stores, undefined, undefined, customOptions, db);
}
export async function setupAppWithAuth(

View File

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