mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02:00
feat: oss import (#3123)
This commit is contained in:
parent
0e7eca059b
commit
f0c9f8b08b
@ -145,11 +145,6 @@ export const Project = () => {
|
||||
<PermissionIconButton
|
||||
permission={CREATE_FEATURE}
|
||||
projectId={projectId}
|
||||
sx={{
|
||||
visibility: isOss()
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}}
|
||||
onClick={() => setModalOpen(true)}
|
||||
tooltipProps={{ title: 'Import' }}
|
||||
data-testid={IMPORT_BUTTON}
|
||||
@ -159,34 +154,42 @@ export const Project = () => {
|
||||
</PermissionIconButton>
|
||||
}
|
||||
/>
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
sx={{
|
||||
visibility: isOss() ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/projects/${projectId}/edit`)
|
||||
<ConditionallyRender
|
||||
condition={!isOss()}
|
||||
show={
|
||||
<PermissionIconButton
|
||||
permission={UPDATE_PROJECT}
|
||||
projectId={projectId}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${projectId}/edit`
|
||||
)
|
||||
}
|
||||
tooltipProps={{ title: 'Edit project' }}
|
||||
data-loading
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
}
|
||||
tooltipProps={{ title: 'Edit project' }}
|
||||
data-loading
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
permission={DELETE_PROJECT}
|
||||
projectId={projectId}
|
||||
sx={{
|
||||
visibility: isOss() ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowDelDialog(true);
|
||||
}}
|
||||
tooltipProps={{ title: 'Delete project' }}
|
||||
data-loading
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={!isOss()}
|
||||
show={
|
||||
<PermissionIconButton
|
||||
permission={DELETE_PROJECT}
|
||||
projectId={projectId}
|
||||
onClick={() => {
|
||||
setShowDelDialog(true);
|
||||
}}
|
||||
tooltipProps={{
|
||||
title: 'Delete project',
|
||||
}}
|
||||
data-loading
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
}
|
||||
/>
|
||||
</StyledDiv>
|
||||
</StyledTopRow>
|
||||
<ConditionallyRender
|
||||
|
59
src/lib/access/index.ts
Normal file
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
|
||||
app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);
|
||||
app.use(`${baseUriPath}/`, new IndexRouter(config, services, db).router);
|
||||
|
||||
if (services.openApiService) {
|
||||
services.openApiService.useErrorHandler(app);
|
||||
|
@ -36,6 +36,7 @@ import { FavoriteProjectsStore } from './favorite-projects-store';
|
||||
import { AccountStore } from './account-store';
|
||||
import ProjectStatsStore from './project-stats-store';
|
||||
import { Db } from './db';
|
||||
import { ImportTogglesStore } from '../export-import-toggles/import-toggles-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -115,6 +116,7 @@ export const createStores = (
|
||||
getLogger,
|
||||
),
|
||||
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
|
||||
importTogglesStore: new ImportTogglesStore(db),
|
||||
};
|
||||
};
|
||||
|
||||
|
22
src/lib/db/transaction.ts
Normal file
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 './util';
|
||||
export * from './error';
|
||||
export * from './access';
|
||||
export * from './export-import-toggles';
|
||||
export * from './feature-toggle';
|
||||
|
@ -128,6 +128,9 @@ import {
|
||||
variantsSchema,
|
||||
versionSchema,
|
||||
projectOverviewSchema,
|
||||
importTogglesSchema,
|
||||
importTogglesValidateSchema,
|
||||
importTogglesValidateItemSchema,
|
||||
} from './spec';
|
||||
import { IServerOption } from '../types';
|
||||
import { mapValues, omitKeys } from '../util';
|
||||
@ -271,6 +274,9 @@ export const schemas = {
|
||||
variantsSchema,
|
||||
versionSchema,
|
||||
projectOverviewSchema,
|
||||
importTogglesSchema,
|
||||
importTogglesValidateSchema,
|
||||
importTogglesValidateItemSchema,
|
||||
};
|
||||
|
||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||
|
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 './project-stats-schema';
|
||||
export * from './project-overview-schema';
|
||||
export * from './import-toggles-validate-item-schema';
|
||||
export * from './import-toggles-validate-schema';
|
||||
export * from './import-toggles-schema';
|
||||
|
@ -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 FavoritesController from './favorites';
|
||||
import MaintenanceController from './maintenance';
|
||||
import ExportImportController from './export-import';
|
||||
import { createKnexTransactionStarter } from '../../db/transaction';
|
||||
import { Db } from '../../db/db';
|
||||
import ExportImportController from '../../export-import-toggles/export-import-controller';
|
||||
|
||||
class AdminApi extends Controller {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
||||
super(config);
|
||||
|
||||
this.app.use(
|
||||
@ -80,7 +82,11 @@ class AdminApi extends Controller {
|
||||
this.app.use('/state', new StateController(config, services).router);
|
||||
this.app.use(
|
||||
'/features-batch',
|
||||
new ExportImportController(config, services).router,
|
||||
new ExportImportController(
|
||||
config,
|
||||
services,
|
||||
createKnexTransactionStarter(db),
|
||||
).router,
|
||||
);
|
||||
this.app.use('/tags', new TagController(config, services).router);
|
||||
this.app.use(
|
||||
|
@ -13,9 +13,10 @@ import ProxyController from './proxy-api';
|
||||
import { conditionalMiddleware } from '../middleware';
|
||||
import EdgeController from './edge-api';
|
||||
import { PublicInviteController } from './public-invite';
|
||||
import { Db } from '../db/db';
|
||||
|
||||
class IndexRouter extends Controller {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
|
||||
super(config);
|
||||
|
||||
this.use('/health', new HealthCheckController(config, services).router);
|
||||
@ -40,7 +41,7 @@ class IndexRouter extends Controller {
|
||||
new ResetPasswordController(config, services).router,
|
||||
);
|
||||
|
||||
this.use('/api/admin', new AdminApi(config, services).router);
|
||||
this.use('/api/admin', new AdminApi(config, services, db).router);
|
||||
this.use('/api/client', new ClientApi(config, services).router);
|
||||
|
||||
this.use(
|
||||
|
@ -42,7 +42,7 @@ async function createApp(
|
||||
const serverVersion = version;
|
||||
const db = createDb(config);
|
||||
const stores = createStores(config, db);
|
||||
const services = createServices(stores, config);
|
||||
const services = createServices(stores, config, db);
|
||||
scheduleServices(services, config);
|
||||
|
||||
const metricsMonitor = createMetricsMonitor();
|
||||
|
@ -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 { FavoritesService } from './favorites-service';
|
||||
import MaintenanceService from './maintenance-service';
|
||||
import ExportImportService from './export-import-service';
|
||||
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
||||
import { AccountService } from './account-service';
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
createExportImportTogglesService,
|
||||
createFakeExportImportTogglesService,
|
||||
} from '../export-import-toggles';
|
||||
import { Db } from '../db/db';
|
||||
|
||||
// TODO: will be moved to scheduler feature directory
|
||||
export const scheduleServices = (
|
||||
@ -96,6 +101,7 @@ export const scheduleServices = (
|
||||
export const createServices = (
|
||||
stores: IUnleashStores,
|
||||
config: IUnleashConfig,
|
||||
db?: Db,
|
||||
): IUnleashServices => {
|
||||
const groupService = new GroupService(stores, config);
|
||||
const accessService = new AccessService(stores, config, groupService);
|
||||
@ -139,9 +145,6 @@ export const createServices = (
|
||||
segmentService,
|
||||
accessService,
|
||||
);
|
||||
const exportImportService = new ExportImportService(stores, config, {
|
||||
featureToggleService: featureToggleServiceV2,
|
||||
});
|
||||
const environmentService = new EnvironmentService(stores, config);
|
||||
const featureTagService = new FeatureTagService(stores, config);
|
||||
const favoritesService = new FavoritesService(stores, config);
|
||||
@ -158,6 +161,11 @@ export const createServices = (
|
||||
config,
|
||||
projectService,
|
||||
);
|
||||
const exportImportService = db
|
||||
? createExportImportTogglesService(db, config)
|
||||
: createFakeExportImportTogglesService(config);
|
||||
const transactionalExportImportService = (txDb: Knex.Transaction) =>
|
||||
createExportImportTogglesService(txDb, config);
|
||||
const userSplashService = new UserSplashService(stores, config);
|
||||
const openApiService = new OpenApiService(config);
|
||||
const clientSpecService = new ClientSpecService(config);
|
||||
@ -239,6 +247,7 @@ export const createServices = (
|
||||
favoritesService,
|
||||
maintenanceService,
|
||||
exportImportService,
|
||||
transactionalExportImportService,
|
||||
schedulerService,
|
||||
};
|
||||
};
|
||||
@ -282,6 +291,5 @@ export {
|
||||
LastSeenService,
|
||||
InstanceStatsService,
|
||||
FavoritesService,
|
||||
ExportImportService,
|
||||
SchedulerService,
|
||||
};
|
||||
|
@ -37,9 +37,10 @@ import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
||||
import { InstanceStatsService } from '../services/instance-stats-service';
|
||||
import { FavoritesService } from '../services/favorites-service';
|
||||
import MaintenanceService from '../services/maintenance-service';
|
||||
import ExportImportService from 'lib/services/export-import-service';
|
||||
import ExportImportService from '../export-import-toggles/export-import-service';
|
||||
import { AccountService } from '../services/account-service';
|
||||
import { SchedulerService } from '../services/scheduler-service';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export interface IUnleashServices {
|
||||
accessService: AccessService;
|
||||
@ -85,4 +86,7 @@ export interface IUnleashServices {
|
||||
maintenanceService: MaintenanceService;
|
||||
exportImportService: ExportImportService;
|
||||
schedulerService: SchedulerService;
|
||||
transactionalExportImportService: (
|
||||
db: Knex.Transaction,
|
||||
) => ExportImportService;
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
||||
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
||||
import { IAccountStore } from './stores/account-store';
|
||||
import { IProjectStatsStore } from './stores/project-stats-store-type';
|
||||
import { IImportTogglesStore } from '../export-import-toggles/import-toggles-store-type';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -68,6 +69,7 @@ export interface IUnleashStores {
|
||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
favoriteProjectsStore: IFavoriteProjectsStore;
|
||||
projectStatsStore: IProjectStatsStore;
|
||||
importTogglesStore: IImportTogglesStore;
|
||||
}
|
||||
|
||||
export {
|
||||
@ -104,4 +106,5 @@ export {
|
||||
IUserStore,
|
||||
IFavoriteFeaturesStore,
|
||||
IFavoriteProjectsStore,
|
||||
IImportTogglesStore,
|
||||
};
|
||||
|
@ -15,8 +15,12 @@ import {
|
||||
IVariant,
|
||||
} from 'lib/types';
|
||||
import { DEFAULT_ENV } from '../../../../lib/util';
|
||||
import { ContextFieldSchema } from '../../../../lib/openapi';
|
||||
import {
|
||||
ContextFieldSchema,
|
||||
ImportTogglesSchema,
|
||||
} from '../../../../lib/openapi';
|
||||
import User from '../../../../lib/types/user';
|
||||
import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
@ -120,15 +124,50 @@ const createSegment = (postData: object): Promise<ISegment> => {
|
||||
});
|
||||
};
|
||||
|
||||
const createContextField = async (contextField: IContextFieldDto) => {
|
||||
await app.request.post(`/api/admin/context`).send(contextField).expect(201);
|
||||
};
|
||||
|
||||
const createFeature = async (featureName: string, project: string) => {
|
||||
await app.request
|
||||
.post(`/api/admin/projects/${project}/features`)
|
||||
.send({
|
||||
name: featureName,
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201);
|
||||
};
|
||||
|
||||
const archiveFeature = async (featureName: string, project: string) => {
|
||||
await app.request
|
||||
.delete(`/api/admin/projects/${project}/features/${featureName}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(202);
|
||||
};
|
||||
|
||||
const unArchiveFeature = async (featureName: string) => {
|
||||
await app.request
|
||||
.post(`/api/admin/archive/revive/${featureName}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
};
|
||||
|
||||
const getContextField = (name: string) =>
|
||||
app.request.get(`/api/admin/context/${name}`).expect(200);
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('export_import_api_serial', getLogger);
|
||||
app = await setupAppWithCustomConfig(db.stores, {
|
||||
experimental: {
|
||||
flags: {
|
||||
featuresExportImport: true,
|
||||
app = await setupAppWithCustomConfig(
|
||||
db.stores,
|
||||
{
|
||||
experimental: {
|
||||
flags: {
|
||||
featuresExportImport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
db.rawDatabase,
|
||||
);
|
||||
eventStore = db.stores.eventStore;
|
||||
environmentStore = db.stores.environmentStore;
|
||||
projectStore = db.stores.projectStore;
|
||||
@ -371,3 +410,371 @@ test('returns no features, when no feature was requested', async () => {
|
||||
|
||||
expect(body.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
const importToggles = (
|
||||
importPayload: ImportTogglesSchema,
|
||||
status = 200,
|
||||
expect: (response) => void = () => {},
|
||||
) =>
|
||||
app.request
|
||||
.post('/api/admin/features-batch/full-import')
|
||||
.send(importPayload)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(status)
|
||||
.expect(expect);
|
||||
|
||||
const defaultFeature = 'first_feature';
|
||||
const defaultProject = 'default';
|
||||
const defaultEnvironment = 'defalt';
|
||||
|
||||
const variants: ImportTogglesSchema['data']['featureEnvironments'][0]['variants'] =
|
||||
[
|
||||
{
|
||||
name: 'variantA',
|
||||
weight: 500,
|
||||
payload: {
|
||||
type: 'string',
|
||||
value: 'payloadA',
|
||||
},
|
||||
overrides: [],
|
||||
stickiness: 'default',
|
||||
weightType: 'variable',
|
||||
},
|
||||
{
|
||||
name: 'variantB',
|
||||
weight: 500,
|
||||
payload: {
|
||||
type: 'string',
|
||||
value: 'payloadB',
|
||||
},
|
||||
overrides: [],
|
||||
stickiness: 'default',
|
||||
weightType: 'variable',
|
||||
},
|
||||
];
|
||||
const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
|
||||
project: 'old_project',
|
||||
name: 'first_feature',
|
||||
};
|
||||
const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constraints'] =
|
||||
[
|
||||
{
|
||||
values: ['conduit'],
|
||||
inverted: false,
|
||||
operator: 'IN',
|
||||
contextName: 'appName',
|
||||
caseInsensitive: false,
|
||||
},
|
||||
];
|
||||
const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
|
||||
featureName: defaultFeature,
|
||||
id: '798cb25a-2abd-47bd-8a95-40ec13472309',
|
||||
name: 'default',
|
||||
parameters: {},
|
||||
constraints,
|
||||
};
|
||||
|
||||
const tags = [
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
tagType: 'simple',
|
||||
tagValue: 'tag1',
|
||||
},
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
tagType: 'simple',
|
||||
tagValue: 'tag2',
|
||||
},
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
tagType: 'special_tag',
|
||||
tagValue: 'feature_tagged',
|
||||
},
|
||||
];
|
||||
|
||||
const resultTags = [
|
||||
{ value: 'tag1', type: 'simple' },
|
||||
{ value: 'tag2', type: 'simple' },
|
||||
{ value: 'feature_tagged', type: 'special_tag' },
|
||||
];
|
||||
|
||||
const tagTypes = [
|
||||
{ name: 'bestt', description: 'test' },
|
||||
{ name: 'special_tag', description: 'this is my special tag' },
|
||||
{ name: 'special_tag', description: 'this is my special tag' }, // deliberate duplicate
|
||||
];
|
||||
|
||||
const defaultImportPayload: ImportTogglesSchema = {
|
||||
data: {
|
||||
features: [exportedFeature],
|
||||
featureStrategies: [exportedStrategy],
|
||||
featureEnvironments: [
|
||||
{
|
||||
enabled: true,
|
||||
environment: 'irrelevant',
|
||||
featureName: defaultFeature,
|
||||
name: defaultFeature,
|
||||
variants,
|
||||
},
|
||||
],
|
||||
featureTags: tags,
|
||||
tagTypes,
|
||||
contextFields: [],
|
||||
segments: [],
|
||||
},
|
||||
project: defaultProject,
|
||||
environment: defaultEnvironment,
|
||||
};
|
||||
|
||||
const getFeature = async (feature: string) =>
|
||||
app.request.get(`/api/admin/features/${feature}`).expect(200);
|
||||
|
||||
const getFeatureEnvironment = (
|
||||
project: string,
|
||||
feature: string,
|
||||
environment: string,
|
||||
) =>
|
||||
app.request
|
||||
.get(
|
||||
`/api/admin/projects/${project}/features/${feature}/environments/${environment}`,
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const getTags = (feature: string) =>
|
||||
app.request.get(`/api/admin/features/${feature}/tags`).expect(200);
|
||||
|
||||
const validateImport = (importPayload: ImportTogglesSchema, status = 200) =>
|
||||
app.request
|
||||
.post('/api/admin/features-batch/full-validate')
|
||||
.send(importPayload)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(status);
|
||||
|
||||
test('import features to existing project and environment', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
|
||||
await importToggles(defaultImportPayload);
|
||||
|
||||
const { body: importedFeature } = await getFeature(defaultFeature);
|
||||
expect(importedFeature).toMatchObject({
|
||||
name: 'first_feature',
|
||||
project: defaultProject,
|
||||
variants,
|
||||
});
|
||||
|
||||
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
||||
defaultProject,
|
||||
defaultFeature,
|
||||
defaultEnvironment,
|
||||
);
|
||||
expect(importedFeatureEnvironment).toMatchObject({
|
||||
name: defaultFeature,
|
||||
environment: defaultEnvironment,
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
parameters: {},
|
||||
constraints,
|
||||
sortOrder: 9999,
|
||||
name: 'default',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { body: importedTags } = await getTags(defaultFeature);
|
||||
expect(importedTags).toMatchObject({
|
||||
tags: resultTags,
|
||||
});
|
||||
});
|
||||
|
||||
test('importing same JSON should work multiple times in a row', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
await importToggles(defaultImportPayload);
|
||||
await importToggles(defaultImportPayload);
|
||||
|
||||
const { body: importedFeature } = await getFeature(defaultFeature);
|
||||
expect(importedFeature).toMatchObject({
|
||||
name: 'first_feature',
|
||||
project: defaultProject,
|
||||
variants,
|
||||
});
|
||||
|
||||
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
||||
defaultProject,
|
||||
defaultFeature,
|
||||
defaultEnvironment,
|
||||
);
|
||||
|
||||
expect(importedFeatureEnvironment).toMatchObject({
|
||||
name: defaultFeature,
|
||||
environment: defaultEnvironment,
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
parameters: {},
|
||||
constraints,
|
||||
sortOrder: 9999,
|
||||
name: 'default',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('reject import with unknown context fields', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
const contextField = {
|
||||
name: 'ContextField1',
|
||||
legalValues: [{ value: 'Value1', description: '' }],
|
||||
};
|
||||
await createContextField(contextField);
|
||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
contextFields: [
|
||||
{
|
||||
...contextField,
|
||||
legalValues: [{ value: 'Value2', description: '' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { body } = await importToggles(importPayloadWithContextFields, 400);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
details: [
|
||||
{
|
||||
message: 'Context fields with errors: ContextField1',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('reject import with unsupported strategies', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
featureStrategies: [{ name: 'customStrategy' }],
|
||||
},
|
||||
};
|
||||
|
||||
const { body } = await importToggles(importPayloadWithContextFields, 400);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
details: [
|
||||
{
|
||||
message: 'Unsupported strategies: customStrategy',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('validate import data', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
const contextField: IContextFieldDto = {
|
||||
name: 'validate_context_field',
|
||||
legalValues: [{ value: 'Value1' }],
|
||||
};
|
||||
|
||||
const createdContextField: IContextFieldDto = {
|
||||
name: 'created_context_field',
|
||||
legalValues: [{ value: 'new_value' }],
|
||||
};
|
||||
|
||||
await createFeature(defaultFeature, defaultProject);
|
||||
await archiveFeature(defaultFeature, defaultProject);
|
||||
|
||||
await createContextField(contextField);
|
||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
featureStrategies: [{ name: 'customStrategy' }],
|
||||
segments: [{ id: 1, name: 'customSegment' }],
|
||||
contextFields: [
|
||||
{
|
||||
...contextField,
|
||||
legalValues: [{ value: 'Value2' }],
|
||||
},
|
||||
createdContextField,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { body } = await validateImport(importPayloadWithContextFields, 200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'We detected the following custom strategy in the import file that needs to be created first:',
|
||||
affectedItems: ['customStrategy'],
|
||||
},
|
||||
{
|
||||
message:
|
||||
'We detected the following context fields that do not have matching legal values with the imported ones:',
|
||||
affectedItems: [contextField.name],
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
{
|
||||
message:
|
||||
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
|
||||
affectedItems: [defaultFeature],
|
||||
},
|
||||
],
|
||||
permissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should create new context', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
const context = {
|
||||
name: 'create-new-context',
|
||||
legalValues: [{ value: 'Value1' }],
|
||||
};
|
||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
contextFields: [context],
|
||||
},
|
||||
};
|
||||
|
||||
await importToggles(importPayloadWithContextFields, 200);
|
||||
|
||||
const { body } = await getContextField(context.name);
|
||||
expect(body).toMatchObject(context);
|
||||
});
|
||||
|
||||
test('should not import archived features tags', async () => {
|
||||
await createProject(defaultProject, defaultEnvironment);
|
||||
await importToggles(defaultImportPayload);
|
||||
|
||||
await archiveFeature(defaultFeature, defaultProject);
|
||||
|
||||
await importToggles({
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
featureTags: [
|
||||
{
|
||||
featureName: defaultFeature,
|
||||
tagType: 'simple',
|
||||
tagValue: 'tag2',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await unArchiveFeature(defaultFeature);
|
||||
|
||||
const { body: importedTags } = await getTags(defaultFeature);
|
||||
expect(importedTags).toMatchObject({
|
||||
tags: resultTags,
|
||||
});
|
||||
});
|
||||
|
@ -1874,6 +1874,73 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"importTogglesSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/exportResultSchema",
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"project",
|
||||
"environment",
|
||||
"data",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"importTogglesValidateItemSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"affectedItems": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"affectedItems",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"importTogglesValidateSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"errors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/importTogglesValidateItemSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"permissions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/importTogglesValidateItemSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"warnings": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/importTogglesValidateItemSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"errors",
|
||||
"warnings",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"instanceAdminStatsSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@ -5062,6 +5129,65 @@ If the provided project does not exist, the list of events will be empty.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/features-batch/full-import": {
|
||||
"post": {
|
||||
"description": "Unleash toggles exported from a different instance can be imported into a new project and environment",
|
||||
"operationId": "importToggles",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/importTogglesSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "importTogglesSchema",
|
||||
"required": true,
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This response has no body.",
|
||||
},
|
||||
},
|
||||
"summary": "Import feature toggles for an environment in the project",
|
||||
"tags": [
|
||||
"Unstable",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/features-batch/full-validate": {
|
||||
"post": {
|
||||
"description": "Unleash toggles exported from a different instance can be imported into a new project and environment",
|
||||
"operationId": "validateImport",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/importTogglesSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "importTogglesSchema",
|
||||
"required": true,
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/importTogglesValidateSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "importTogglesValidateSchema",
|
||||
},
|
||||
},
|
||||
"summary": "Validate import of feature toggles for an environment in the project",
|
||||
"tags": [
|
||||
"Unstable",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/features/validate": {
|
||||
"post": {
|
||||
"operationId": "validateFeature",
|
||||
|
@ -9,6 +9,7 @@ import { createServices } from '../../../lib/services';
|
||||
import sessionDb from '../../../lib/middleware/session-db';
|
||||
import { IUnleashStores } from '../../../lib/types';
|
||||
import { IUnleashServices } from '../../../lib/types/services';
|
||||
import { Db } from '../../../lib/db/db';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
@ -24,6 +25,7 @@ async function createApp(
|
||||
adminAuthentication = IAuthType.NONE,
|
||||
preHook?: Function,
|
||||
customOptions?: any,
|
||||
db?: Db,
|
||||
): Promise<IUnleashTest> {
|
||||
const config = createTestConfig({
|
||||
authentication: {
|
||||
@ -35,11 +37,11 @@ async function createApp(
|
||||
},
|
||||
...customOptions,
|
||||
});
|
||||
const services = createServices(stores, config);
|
||||
const services = createServices(stores, config, db);
|
||||
const unleashSession = sessionDb(config, undefined);
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(0);
|
||||
const app = await getApp(config, stores, services, unleashSession);
|
||||
const app = await getApp(config, stores, services, unleashSession, db);
|
||||
const request = supertest.agent(app);
|
||||
|
||||
const destroy = async () => {
|
||||
@ -60,8 +62,9 @@ export async function setupApp(stores: IUnleashStores): Promise<IUnleashTest> {
|
||||
export async function setupAppWithCustomConfig(
|
||||
stores: IUnleashStores,
|
||||
customOptions: any,
|
||||
db?: Db,
|
||||
): Promise<IUnleashTest> {
|
||||
return createApp(stores, undefined, undefined, customOptions);
|
||||
return createApp(stores, undefined, undefined, customOptions, db);
|
||||
}
|
||||
|
||||
export async function setupAppWithAuth(
|
||||
|
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 FakeEnvironmentStore from './fake-environment-store';
|
||||
import FakeStrategiesStore from './fake-strategies-store';
|
||||
import { IUnleashStores } from '../../lib/types';
|
||||
import { IImportTogglesStore, IUnleashStores } from '../../lib/types';
|
||||
import FakeSessionStore from './fake-session-store';
|
||||
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
|
||||
import FakeApiTokenStore from './fake-api-token-store';
|
||||
@ -34,13 +34,13 @@ import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
|
||||
import { FakeAccountStore } from './fake-account-store';
|
||||
import FakeProjectStatsStore from './fake-project-stats-store';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
select: () => ({
|
||||
from: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
select: () => ({
|
||||
from: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
return {
|
||||
db,
|
||||
clientApplicationsStore: new FakeClientApplicationsStore(),
|
||||
@ -77,6 +77,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
||||
projectStatsStore: new FakeProjectStatsStore(),
|
||||
importTogglesStore: {} as IImportTogglesStore,
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user