mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-14 01:16:17 +02:00
feat: oss import (#3123)
This commit is contained in:
parent
0e7eca059b
commit
f0c9f8b08b
@ -145,11 +145,6 @@ export const Project = () => {
|
|||||||
<PermissionIconButton
|
<PermissionIconButton
|
||||||
permission={CREATE_FEATURE}
|
permission={CREATE_FEATURE}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
sx={{
|
|
||||||
visibility: isOss()
|
|
||||||
? 'hidden'
|
|
||||||
: 'visible',
|
|
||||||
}}
|
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
tooltipProps={{ title: 'Import' }}
|
tooltipProps={{ title: 'Import' }}
|
||||||
data-testid={IMPORT_BUTTON}
|
data-testid={IMPORT_BUTTON}
|
||||||
@ -159,34 +154,42 @@ export const Project = () => {
|
|||||||
</PermissionIconButton>
|
</PermissionIconButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PermissionIconButton
|
<ConditionallyRender
|
||||||
permission={UPDATE_PROJECT}
|
condition={!isOss()}
|
||||||
projectId={projectId}
|
show={
|
||||||
sx={{
|
<PermissionIconButton
|
||||||
visibility: isOss() ? 'hidden' : 'visible',
|
permission={UPDATE_PROJECT}
|
||||||
}}
|
projectId={projectId}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/projects/${projectId}/edit`)
|
navigate(
|
||||||
|
`/projects/${projectId}/edit`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tooltipProps={{ title: 'Edit project' }}
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</PermissionIconButton>
|
||||||
}
|
}
|
||||||
tooltipProps={{ title: 'Edit project' }}
|
/>
|
||||||
data-loading
|
<ConditionallyRender
|
||||||
>
|
condition={!isOss()}
|
||||||
<Edit />
|
show={
|
||||||
</PermissionIconButton>
|
<PermissionIconButton
|
||||||
<PermissionIconButton
|
permission={DELETE_PROJECT}
|
||||||
permission={DELETE_PROJECT}
|
projectId={projectId}
|
||||||
projectId={projectId}
|
onClick={() => {
|
||||||
sx={{
|
setShowDelDialog(true);
|
||||||
visibility: isOss() ? 'hidden' : 'visible',
|
}}
|
||||||
}}
|
tooltipProps={{
|
||||||
onClick={() => {
|
title: 'Delete project',
|
||||||
setShowDelDialog(true);
|
}}
|
||||||
}}
|
data-loading
|
||||||
tooltipProps={{ title: 'Delete project' }}
|
>
|
||||||
data-loading
|
<Delete />
|
||||||
>
|
</PermissionIconButton>
|
||||||
<Delete />
|
}
|
||||||
</PermissionIconButton>
|
/>
|
||||||
</StyledDiv>
|
</StyledDiv>
|
||||||
</StyledTopRow>
|
</StyledTopRow>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
|
59
src/lib/access/index.ts
Normal file
59
src/lib/access/index.ts
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
@ -169,7 +169,7 @@ export default async function getApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup API routes
|
// Setup API routes
|
||||||
app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);
|
app.use(`${baseUriPath}/`, new IndexRouter(config, services, db).router);
|
||||||
|
|
||||||
if (services.openApiService) {
|
if (services.openApiService) {
|
||||||
services.openApiService.useErrorHandler(app);
|
services.openApiService.useErrorHandler(app);
|
||||||
|
@ -36,6 +36,7 @@ import { FavoriteProjectsStore } from './favorite-projects-store';
|
|||||||
import { AccountStore } from './account-store';
|
import { AccountStore } from './account-store';
|
||||||
import ProjectStatsStore from './project-stats-store';
|
import ProjectStatsStore from './project-stats-store';
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
import { ImportTogglesStore } from '../export-import-toggles/import-toggles-store';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -115,6 +116,7 @@ export const createStores = (
|
|||||||
getLogger,
|
getLogger,
|
||||||
),
|
),
|
||||||
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
|
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
|
||||||
|
importTogglesStore: new ImportTogglesStore(db),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
22
src/lib/db/transaction.ts
Normal file
22
src/lib/db/transaction.ts
Normal 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;
|
||||||
|
};
|
177
src/lib/export-import-toggles/export-import-controller.ts
Normal file
177
src/lib/export-import-toggles/export-import-controller.ts
Normal 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;
|
744
src/lib/export-import-toggles/export-import-service.ts
Normal file
744
src/lib/export-import-toggles/export-import-service.ts
Normal 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;
|
@ -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);
|
||||||
|
});
|
16
src/lib/export-import-toggles/import-context-validation.ts
Normal file
16
src/lib/export-import-toggles/import-context-validation.ts
Normal 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),
|
||||||
|
);
|
||||||
|
};
|
19
src/lib/export-import-toggles/import-toggles-store-type.ts
Normal file
19
src/lib/export-import-toggles/import-toggles-store-type.ts
Normal 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 }[]>;
|
||||||
|
}
|
60
src/lib/export-import-toggles/import-toggles-store.ts
Normal file
60
src/lib/export-import-toggles/import-toggles-store.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
192
src/lib/export-import-toggles/index.ts
Normal file
192
src/lib/export-import-toggles/index.ts
Normal 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;
|
||||||
|
};
|
155
src/lib/feature-toggle/index.ts
Normal file
155
src/lib/feature-toggle/index.ts
Normal 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;
|
||||||
|
};
|
@ -13,3 +13,6 @@ export * from './services';
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './util';
|
export * from './util';
|
||||||
export * from './error';
|
export * from './error';
|
||||||
|
export * from './access';
|
||||||
|
export * from './export-import-toggles';
|
||||||
|
export * from './feature-toggle';
|
||||||
|
@ -128,6 +128,9 @@ import {
|
|||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
projectOverviewSchema,
|
projectOverviewSchema,
|
||||||
|
importTogglesSchema,
|
||||||
|
importTogglesValidateSchema,
|
||||||
|
importTogglesValidateItemSchema,
|
||||||
} from './spec';
|
} from './spec';
|
||||||
import { IServerOption } from '../types';
|
import { IServerOption } from '../types';
|
||||||
import { mapValues, omitKeys } from '../util';
|
import { mapValues, omitKeys } from '../util';
|
||||||
@ -271,6 +274,9 @@ export const schemas = {
|
|||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
projectOverviewSchema,
|
projectOverviewSchema,
|
||||||
|
importTogglesSchema,
|
||||||
|
importTogglesValidateSchema,
|
||||||
|
importTogglesValidateItemSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||||
|
53
src/lib/openapi/spec/import-toggles-schema.ts
Normal file
53
src/lib/openapi/spec/import-toggles-schema.ts
Normal 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>;
|
26
src/lib/openapi/spec/import-toggles-validate-item-schema.ts
Normal file
26
src/lib/openapi/spec/import-toggles-validate-item-schema.ts
Normal 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
|
||||||
|
>;
|
38
src/lib/openapi/spec/import-toggles-validate-schema.ts
Normal file
38
src/lib/openapi/spec/import-toggles-validate-schema.ts
Normal 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
|
||||||
|
>;
|
@ -127,3 +127,6 @@ export * from './export-query-schema';
|
|||||||
export * from './push-variants-schema';
|
export * from './push-variants-schema';
|
||||||
export * from './project-stats-schema';
|
export * from './project-stats-schema';
|
||||||
export * from './project-overview-schema';
|
export * from './project-overview-schema';
|
||||||
|
export * from './import-toggles-validate-item-schema';
|
||||||
|
export * from './import-toggles-validate-schema';
|
||||||
|
export * from './import-toggles-schema';
|
||||||
|
@ -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;
|
|
@ -28,10 +28,12 @@ import { PublicSignupController } from './public-signup';
|
|||||||
import InstanceAdminController from './instance-admin';
|
import InstanceAdminController from './instance-admin';
|
||||||
import FavoritesController from './favorites';
|
import FavoritesController from './favorites';
|
||||||
import MaintenanceController from './maintenance';
|
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 {
|
class AdminApi extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.app.use(
|
this.app.use(
|
||||||
@ -80,7 +82,11 @@ class AdminApi extends Controller {
|
|||||||
this.app.use('/state', new StateController(config, services).router);
|
this.app.use('/state', new StateController(config, services).router);
|
||||||
this.app.use(
|
this.app.use(
|
||||||
'/features-batch',
|
'/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('/tags', new TagController(config, services).router);
|
||||||
this.app.use(
|
this.app.use(
|
||||||
|
@ -13,9 +13,10 @@ import ProxyController from './proxy-api';
|
|||||||
import { conditionalMiddleware } from '../middleware';
|
import { conditionalMiddleware } from '../middleware';
|
||||||
import EdgeController from './edge-api';
|
import EdgeController from './edge-api';
|
||||||
import { PublicInviteController } from './public-invite';
|
import { PublicInviteController } from './public-invite';
|
||||||
|
import { Db } from '../db/db';
|
||||||
|
|
||||||
class IndexRouter extends Controller {
|
class IndexRouter extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.use('/health', new HealthCheckController(config, services).router);
|
this.use('/health', new HealthCheckController(config, services).router);
|
||||||
@ -40,7 +41,7 @@ class IndexRouter extends Controller {
|
|||||||
new ResetPasswordController(config, services).router,
|
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('/api/client', new ClientApi(config, services).router);
|
||||||
|
|
||||||
this.use(
|
this.use(
|
||||||
|
@ -42,7 +42,7 @@ async function createApp(
|
|||||||
const serverVersion = version;
|
const serverVersion = version;
|
||||||
const db = createDb(config);
|
const db = createDb(config);
|
||||||
const stores = createStores(config, db);
|
const stores = createStores(config, db);
|
||||||
const services = createServices(stores, config);
|
const services = createServices(stores, config, db);
|
||||||
scheduleServices(services, config);
|
scheduleServices(services, config);
|
||||||
|
|
||||||
const metricsMonitor = createMetricsMonitor();
|
const metricsMonitor = createMetricsMonitor();
|
||||||
|
@ -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;
|
|
@ -39,10 +39,15 @@ import { LastSeenService } from './client-metrics/last-seen-service';
|
|||||||
import { InstanceStatsService } from './instance-stats-service';
|
import { InstanceStatsService } from './instance-stats-service';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
import MaintenanceService from './maintenance-service';
|
import MaintenanceService from './maintenance-service';
|
||||||
import ExportImportService from './export-import-service';
|
|
||||||
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
||||||
import { AccountService } from './account-service';
|
import { AccountService } from './account-service';
|
||||||
import { SchedulerService } from './scheduler-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
|
// TODO: will be moved to scheduler feature directory
|
||||||
export const scheduleServices = (
|
export const scheduleServices = (
|
||||||
@ -96,6 +101,7 @@ export const scheduleServices = (
|
|||||||
export const createServices = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
|
db?: Db,
|
||||||
): IUnleashServices => {
|
): IUnleashServices => {
|
||||||
const groupService = new GroupService(stores, config);
|
const groupService = new GroupService(stores, config);
|
||||||
const accessService = new AccessService(stores, config, groupService);
|
const accessService = new AccessService(stores, config, groupService);
|
||||||
@ -139,9 +145,6 @@ export const createServices = (
|
|||||||
segmentService,
|
segmentService,
|
||||||
accessService,
|
accessService,
|
||||||
);
|
);
|
||||||
const exportImportService = new ExportImportService(stores, config, {
|
|
||||||
featureToggleService: featureToggleServiceV2,
|
|
||||||
});
|
|
||||||
const environmentService = new EnvironmentService(stores, config);
|
const environmentService = new EnvironmentService(stores, config);
|
||||||
const featureTagService = new FeatureTagService(stores, config);
|
const featureTagService = new FeatureTagService(stores, config);
|
||||||
const favoritesService = new FavoritesService(stores, config);
|
const favoritesService = new FavoritesService(stores, config);
|
||||||
@ -158,6 +161,11 @@ export const createServices = (
|
|||||||
config,
|
config,
|
||||||
projectService,
|
projectService,
|
||||||
);
|
);
|
||||||
|
const exportImportService = db
|
||||||
|
? createExportImportTogglesService(db, config)
|
||||||
|
: createFakeExportImportTogglesService(config);
|
||||||
|
const transactionalExportImportService = (txDb: Knex.Transaction) =>
|
||||||
|
createExportImportTogglesService(txDb, config);
|
||||||
const userSplashService = new UserSplashService(stores, config);
|
const userSplashService = new UserSplashService(stores, config);
|
||||||
const openApiService = new OpenApiService(config);
|
const openApiService = new OpenApiService(config);
|
||||||
const clientSpecService = new ClientSpecService(config);
|
const clientSpecService = new ClientSpecService(config);
|
||||||
@ -239,6 +247,7 @@ export const createServices = (
|
|||||||
favoritesService,
|
favoritesService,
|
||||||
maintenanceService,
|
maintenanceService,
|
||||||
exportImportService,
|
exportImportService,
|
||||||
|
transactionalExportImportService,
|
||||||
schedulerService,
|
schedulerService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -282,6 +291,5 @@ export {
|
|||||||
LastSeenService,
|
LastSeenService,
|
||||||
InstanceStatsService,
|
InstanceStatsService,
|
||||||
FavoritesService,
|
FavoritesService,
|
||||||
ExportImportService,
|
|
||||||
SchedulerService,
|
SchedulerService,
|
||||||
};
|
};
|
||||||
|
@ -37,9 +37,10 @@ import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
|||||||
import { InstanceStatsService } from '../services/instance-stats-service';
|
import { InstanceStatsService } from '../services/instance-stats-service';
|
||||||
import { FavoritesService } from '../services/favorites-service';
|
import { FavoritesService } from '../services/favorites-service';
|
||||||
import MaintenanceService from '../services/maintenance-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 { AccountService } from '../services/account-service';
|
||||||
import { SchedulerService } from '../services/scheduler-service';
|
import { SchedulerService } from '../services/scheduler-service';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
accessService: AccessService;
|
accessService: AccessService;
|
||||||
@ -85,4 +86,7 @@ export interface IUnleashServices {
|
|||||||
maintenanceService: MaintenanceService;
|
maintenanceService: MaintenanceService;
|
||||||
exportImportService: ExportImportService;
|
exportImportService: ExportImportService;
|
||||||
schedulerService: SchedulerService;
|
schedulerService: SchedulerService;
|
||||||
|
transactionalExportImportService: (
|
||||||
|
db: Knex.Transaction,
|
||||||
|
) => ExportImportService;
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,7 @@ import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
|||||||
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
||||||
import { IAccountStore } from './stores/account-store';
|
import { IAccountStore } from './stores/account-store';
|
||||||
import { IProjectStatsStore } from './stores/project-stats-store-type';
|
import { IProjectStatsStore } from './stores/project-stats-store-type';
|
||||||
|
import { IImportTogglesStore } from '../export-import-toggles/import-toggles-store-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -68,6 +69,7 @@ export interface IUnleashStores {
|
|||||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||||
favoriteProjectsStore: IFavoriteProjectsStore;
|
favoriteProjectsStore: IFavoriteProjectsStore;
|
||||||
projectStatsStore: IProjectStatsStore;
|
projectStatsStore: IProjectStatsStore;
|
||||||
|
importTogglesStore: IImportTogglesStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -104,4 +106,5 @@ export {
|
|||||||
IUserStore,
|
IUserStore,
|
||||||
IFavoriteFeaturesStore,
|
IFavoriteFeaturesStore,
|
||||||
IFavoriteProjectsStore,
|
IFavoriteProjectsStore,
|
||||||
|
IImportTogglesStore,
|
||||||
};
|
};
|
||||||
|
@ -15,8 +15,12 @@ import {
|
|||||||
IVariant,
|
IVariant,
|
||||||
} from 'lib/types';
|
} from 'lib/types';
|
||||||
import { DEFAULT_ENV } from '../../../../lib/util';
|
import { DEFAULT_ENV } from '../../../../lib/util';
|
||||||
import { ContextFieldSchema } from '../../../../lib/openapi';
|
import {
|
||||||
|
ContextFieldSchema,
|
||||||
|
ImportTogglesSchema,
|
||||||
|
} from '../../../../lib/openapi';
|
||||||
import User from '../../../../lib/types/user';
|
import User from '../../../../lib/types/user';
|
||||||
|
import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store';
|
||||||
|
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
let db: ITestDb;
|
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 () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('export_import_api_serial', getLogger);
|
db = await dbInit('export_import_api_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(
|
||||||
experimental: {
|
db.stores,
|
||||||
flags: {
|
{
|
||||||
featuresExportImport: true,
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
featuresExportImport: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
db.rawDatabase,
|
||||||
|
);
|
||||||
eventStore = db.stores.eventStore;
|
eventStore = db.stores.eventStore;
|
||||||
environmentStore = db.stores.environmentStore;
|
environmentStore = db.stores.environmentStore;
|
||||||
projectStore = db.stores.projectStore;
|
projectStore = db.stores.projectStore;
|
||||||
@ -371,3 +410,371 @@ test('returns no features, when no feature was requested', async () => {
|
|||||||
|
|
||||||
expect(body.features).toHaveLength(0);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1874,6 +1874,73 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"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": {
|
"instanceAdminStatsSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"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": {
|
"/api/admin/features/validate": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "validateFeature",
|
"operationId": "validateFeature",
|
||||||
|
@ -9,6 +9,7 @@ import { createServices } from '../../../lib/services';
|
|||||||
import sessionDb from '../../../lib/middleware/session-db';
|
import sessionDb from '../../../lib/middleware/session-db';
|
||||||
import { IUnleashStores } from '../../../lib/types';
|
import { IUnleashStores } from '../../../lib/types';
|
||||||
import { IUnleashServices } from '../../../lib/types/services';
|
import { IUnleashServices } from '../../../lib/types/services';
|
||||||
|
import { Db } from '../../../lib/db/db';
|
||||||
|
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ async function createApp(
|
|||||||
adminAuthentication = IAuthType.NONE,
|
adminAuthentication = IAuthType.NONE,
|
||||||
preHook?: Function,
|
preHook?: Function,
|
||||||
customOptions?: any,
|
customOptions?: any,
|
||||||
|
db?: Db,
|
||||||
): Promise<IUnleashTest> {
|
): Promise<IUnleashTest> {
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
authentication: {
|
authentication: {
|
||||||
@ -35,11 +37,11 @@ async function createApp(
|
|||||||
},
|
},
|
||||||
...customOptions,
|
...customOptions,
|
||||||
});
|
});
|
||||||
const services = createServices(stores, config);
|
const services = createServices(stores, config, db);
|
||||||
const unleashSession = sessionDb(config, undefined);
|
const unleashSession = sessionDb(config, undefined);
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
emitter.setMaxListeners(0);
|
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 request = supertest.agent(app);
|
||||||
|
|
||||||
const destroy = async () => {
|
const destroy = async () => {
|
||||||
@ -60,8 +62,9 @@ export async function setupApp(stores: IUnleashStores): Promise<IUnleashTest> {
|
|||||||
export async function setupAppWithCustomConfig(
|
export async function setupAppWithCustomConfig(
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
customOptions: any,
|
customOptions: any,
|
||||||
|
db?: Db,
|
||||||
): Promise<IUnleashTest> {
|
): Promise<IUnleashTest> {
|
||||||
return createApp(stores, undefined, undefined, customOptions);
|
return createApp(stores, undefined, undefined, customOptions, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupAppWithAuth(
|
export async function setupAppWithAuth(
|
||||||
|
15
src/test/fixtures/store.ts
vendored
15
src/test/fixtures/store.ts
vendored
@ -15,7 +15,7 @@ import FakeUserFeedbackStore from './fake-user-feedback-store';
|
|||||||
import FakeFeatureTagStore from './fake-feature-tag-store';
|
import FakeFeatureTagStore from './fake-feature-tag-store';
|
||||||
import FakeEnvironmentStore from './fake-environment-store';
|
import FakeEnvironmentStore from './fake-environment-store';
|
||||||
import FakeStrategiesStore from './fake-strategies-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 FakeSessionStore from './fake-session-store';
|
||||||
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
|
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
|
||||||
import FakeApiTokenStore from './fake-api-token-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 { FakeAccountStore } from './fake-account-store';
|
||||||
import FakeProjectStatsStore from './fake-project-stats-store';
|
import FakeProjectStatsStore from './fake-project-stats-store';
|
||||||
|
|
||||||
const createStores: () => IUnleashStores = () => {
|
const db = {
|
||||||
const db = {
|
select: () => ({
|
||||||
select: () => ({
|
from: () => Promise.resolve(),
|
||||||
from: () => Promise.resolve(),
|
}),
|
||||||
}),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
|
const createStores: () => IUnleashStores = () => {
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
clientApplicationsStore: new FakeClientApplicationsStore(),
|
clientApplicationsStore: new FakeClientApplicationsStore(),
|
||||||
@ -77,6 +77,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||||
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
||||||
projectStatsStore: new FakeProjectStatsStore(),
|
projectStatsStore: new FakeProjectStatsStore(),
|
||||||
|
importTogglesStore: {} as IImportTogglesStore,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user