mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-19 01:17:18 +02:00
Export features (#2905)
This commit is contained in:
parent
1a410a4ed5
commit
b895c99743
@ -108,6 +108,22 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
|
||||
}));
|
||||
}
|
||||
|
||||
async getAllByFeatures(
|
||||
features: string[],
|
||||
environment?: string,
|
||||
): Promise<IFeatureEnvironment[]> {
|
||||
let rows = this.db(T.featureEnvs).whereIn('feature_name', features);
|
||||
if (environment) {
|
||||
rows = rows.where({ environment });
|
||||
}
|
||||
return (await rows).map((r) => ({
|
||||
enabled: r.enabled,
|
||||
featureName: r.feature_name,
|
||||
environment: r.environment,
|
||||
variants: r.variants,
|
||||
}));
|
||||
}
|
||||
|
||||
async disableEnvironmentIfNoStrategies(
|
||||
featureName: string,
|
||||
environment: string,
|
||||
|
@ -16,6 +16,9 @@ export const exportQuerySchema = {
|
||||
environment: {
|
||||
type: 'string',
|
||||
},
|
||||
downloadFile: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {},
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { FromSchema } from 'json-schema-to-ts';
|
||||
import { featureSchema } from './feature-schema';
|
||||
import { featureStrategySchema } from './feature-strategy-schema';
|
||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||
import { contextFieldSchema } from './context-field-schema';
|
||||
import { featureTagSchema } from './feature-tag-schema';
|
||||
|
||||
export const exportResultSchema = {
|
||||
$id: '#/components/schemas/exportResultSchema',
|
||||
@ -20,11 +23,32 @@ export const exportResultSchema = {
|
||||
$ref: '#/components/schemas/featureStrategySchema',
|
||||
},
|
||||
},
|
||||
featureEnvironments: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/featureEnvironmentSchema',
|
||||
},
|
||||
},
|
||||
contextFields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/contextFieldSchema',
|
||||
},
|
||||
},
|
||||
featureTags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/featureTagSchema',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
featureSchema,
|
||||
featureStrategySchema,
|
||||
featureEnvironmentSchema,
|
||||
contextFieldSchema,
|
||||
featureTagSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Response } from 'express';
|
||||
import Controller from '../controller';
|
||||
import { NONE } from '../../types/permissions';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
@ -14,6 +14,8 @@ 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;
|
||||
@ -58,12 +60,13 @@ class ExportImportController extends Controller {
|
||||
}
|
||||
|
||||
async export(
|
||||
req: Request<unknown, unknown, ExportQuerySchema, unknown>,
|
||||
req: IAuthRequest<unknown, unknown, ExportQuerySchema, unknown>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
this.verifyExportImportEnabled();
|
||||
const query = req.body;
|
||||
const data = await this.exportImportService.export(query);
|
||||
const userName = extractUsername(req);
|
||||
const data = await this.exportImportService.export(query, userName);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
@ -71,6 +74,17 @@ class ExportImportController extends Controller {
|
||||
exportResultSchema.$id,
|
||||
serializeDates(data),
|
||||
);
|
||||
|
||||
const timestamp = this.getFormattedDate(Date.now());
|
||||
if (query.downloadFile) {
|
||||
res.attachment(`export-${timestamp}.json`);
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
}
|
||||
|
||||
private getFormattedDate(millis: number): string {
|
||||
return formatDate(millis, 'yyyy-MM-dd_HH-mm-ss');
|
||||
}
|
||||
|
||||
private verifyExportImportEnabled() {
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
FeatureToggle,
|
||||
IFeatureEnvironment,
|
||||
IFeatureStrategy,
|
||||
IFeatureStrategySegment,
|
||||
ITag,
|
||||
} from '../types/model';
|
||||
import { Logger } from '../logger';
|
||||
@ -16,18 +17,13 @@ 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 { IUnleashStores } from '../types/stores';
|
||||
import { IContextFieldStore, IUnleashStores } from '../types/stores';
|
||||
import { ISegmentStore } from '../types/stores/segment-store';
|
||||
import { IFlagResolver, IUnleashServices } from 'lib/types';
|
||||
import { IContextFieldDto } from '../types/stores/context-field-store';
|
||||
import FeatureToggleService from './feature-toggle-service';
|
||||
import User from 'lib/types/user';
|
||||
import { ExportQuerySchema } from '../openapi/spec/export-query-schema';
|
||||
|
||||
export interface IExportQuery {
|
||||
features: string[];
|
||||
environment: string;
|
||||
}
|
||||
import { FEATURES_EXPORTED, IFlagResolver, IUnleashServices } from '../types';
|
||||
|
||||
export interface IImportDTO {
|
||||
data: IExportData;
|
||||
@ -38,7 +34,7 @@ export interface IImportDTO {
|
||||
export interface IExportData {
|
||||
features: FeatureToggle[];
|
||||
tags?: ITag[];
|
||||
contextFields?: IContextFieldDto[];
|
||||
contextFields: IContextFieldDto[];
|
||||
featureStrategies: IFeatureStrategy[];
|
||||
featureEnvironments: IFeatureEnvironment[];
|
||||
}
|
||||
@ -72,6 +68,8 @@ export default class ExportImportService {
|
||||
|
||||
private featureToggleService: FeatureToggleService;
|
||||
|
||||
private contextFieldStore: IContextFieldStore;
|
||||
|
||||
constructor(
|
||||
stores: IUnleashStores,
|
||||
{
|
||||
@ -95,24 +93,71 @@ export default class ExportImportService {
|
||||
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): Promise<IExportData> {
|
||||
const [features, featureEnvironments, featureStrategies] =
|
||||
await Promise.all([
|
||||
async export(
|
||||
query: ExportQuerySchema,
|
||||
userName: string,
|
||||
): Promise<IExportData> {
|
||||
const [
|
||||
features,
|
||||
featureEnvironments,
|
||||
featureStrategies,
|
||||
strategySegments,
|
||||
contextFields,
|
||||
featureTags,
|
||||
] = await Promise.all([
|
||||
this.toggleStore.getAllByNames(query.features),
|
||||
(
|
||||
await this.featureEnvironmentStore.getAll({
|
||||
environment: query.environment,
|
||||
})
|
||||
).filter((item) => query.features.includes(item.featureName)),
|
||||
await this.featureEnvironmentStore.getAllByFeatures(
|
||||
query.features,
|
||||
query.environment,
|
||||
),
|
||||
this.featureStrategiesStore.getAllByFeatures(
|
||||
query.features,
|
||||
query.environment,
|
||||
),
|
||||
this.segmentStore.getAllFeatureStrategySegments(),
|
||||
this.contextFieldStore.getAll(),
|
||||
this.featureTagStore.getAll(),
|
||||
]);
|
||||
return { features, featureStrategies, featureEnvironments };
|
||||
this.addSegmentsToStrategies(featureStrategies, strategySegments);
|
||||
const filteredContextFields = contextFields.filter((field) =>
|
||||
featureStrategies.some((strategy) =>
|
||||
strategy.constraints.some(
|
||||
(constraint) => constraint.contextName === field.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
const result = {
|
||||
features,
|
||||
featureStrategies,
|
||||
featureEnvironments,
|
||||
contextFields: filteredContextFields,
|
||||
featureTags,
|
||||
};
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async import(dto: IImportDTO, user: User): Promise<void> {
|
||||
|
@ -104,6 +104,8 @@ export const FEATURE_UNFAVORITED = 'feature-unfavorited';
|
||||
export const PROJECT_FAVORITED = 'project-favorited';
|
||||
export const PROJECT_UNFAVORITED = 'project-unfavorited';
|
||||
|
||||
export const FEATURES_EXPORTED = 'features-exported';
|
||||
|
||||
export interface IBaseEvent {
|
||||
type: string;
|
||||
createdBy: string;
|
||||
|
@ -15,6 +15,10 @@ export interface IFeatureEnvironmentStore
|
||||
getEnvironmentsForFeature(
|
||||
featureName: string,
|
||||
): Promise<IFeatureEnvironment[]>;
|
||||
getAllByFeatures(
|
||||
features: string[],
|
||||
environment?: string,
|
||||
): Promise<IFeatureEnvironment[]>;
|
||||
isEnvironmentEnabled(
|
||||
featureName: string,
|
||||
environment: string,
|
||||
|
@ -12,10 +12,12 @@ import {
|
||||
IFeatureStrategy,
|
||||
IFeatureToggleStore,
|
||||
IProjectStore,
|
||||
ISegment,
|
||||
IStrategyConfig,
|
||||
} from 'lib/types';
|
||||
import { DEFAULT_ENV } from '../../../../lib/util';
|
||||
import { IImportDTO } from '../../../../lib/services/export-import-service';
|
||||
import { ContextFieldSchema } from '../../../../lib/openapi';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
@ -30,9 +32,19 @@ const defaultStrategy: IStrategyConfig = {
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
const defaultContext: ContextFieldSchema = {
|
||||
name: 'region',
|
||||
description: 'A region',
|
||||
legalValues: [
|
||||
{ value: 'north' },
|
||||
{ value: 'south', description: 'south-desc' },
|
||||
],
|
||||
};
|
||||
|
||||
const createToggle = async (
|
||||
toggle: FeatureToggleDTO,
|
||||
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
|
||||
tags: string[] = [],
|
||||
projectId: string = 'default',
|
||||
username: string = 'test',
|
||||
) => {
|
||||
@ -48,6 +60,26 @@ const createToggle = async (
|
||||
username,
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
tags.map(async (tag) => {
|
||||
return app.services.featureTagService.addTag(
|
||||
toggle.name,
|
||||
{
|
||||
type: 'simple',
|
||||
value: tag,
|
||||
},
|
||||
username,
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const createContext = async (context: ContextFieldSchema = defaultContext) => {
|
||||
await app.request
|
||||
.post('/api/admin/context')
|
||||
.send(context)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201);
|
||||
};
|
||||
|
||||
const createProject = async (project: string, environment: string) => {
|
||||
@ -68,6 +100,12 @@ const createProject = async (project: string, environment: string) => {
|
||||
.expect(200);
|
||||
};
|
||||
|
||||
const createSegment = (postData: object): Promise<ISegment> => {
|
||||
return app.services.segmentService.create(postData, {
|
||||
email: 'test@example.com',
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('export_import_api_serial', getLogger);
|
||||
app = await setupAppWithCustomConfig(db.stores, {
|
||||
@ -97,6 +135,7 @@ afterAll(async () => {
|
||||
|
||||
test('exports features', async () => {
|
||||
await createProject('default', 'default');
|
||||
const segment = await createSegment({ name: 'S3', constraints: [] });
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'default' },
|
||||
@ -107,6 +146,7 @@ test('exports features', async () => {
|
||||
operator: 'IN' as const,
|
||||
},
|
||||
],
|
||||
segments: [segment.id],
|
||||
};
|
||||
await createToggle(
|
||||
{
|
||||
@ -150,6 +190,106 @@ test('exports features', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should export custom context fields', async () => {
|
||||
await createProject('default', 'default');
|
||||
const context = {
|
||||
name: 'test-export',
|
||||
legalValues: [
|
||||
{ value: 'estonia' },
|
||||
{ value: 'norway' },
|
||||
{ value: 'poland' },
|
||||
],
|
||||
};
|
||||
await createContext(context);
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'default' },
|
||||
constraints: [
|
||||
{
|
||||
contextName: context.name,
|
||||
values: ['estonia', 'norway'],
|
||||
operator: 'IN' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
await createToggle(
|
||||
{
|
||||
name: 'first_feature',
|
||||
description: 'the #1 feature',
|
||||
},
|
||||
strategy,
|
||||
);
|
||||
|
||||
const { body } = await app.request
|
||||
.post('/api/admin/features-batch/export')
|
||||
.send({
|
||||
features: ['first_feature'],
|
||||
environment: 'default',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { name, ...resultStrategy } = strategy;
|
||||
expect(body).toMatchObject({
|
||||
features: [
|
||||
{
|
||||
name: 'first_feature',
|
||||
},
|
||||
],
|
||||
featureStrategies: [resultStrategy],
|
||||
featureEnvironments: [
|
||||
{
|
||||
enabled: false,
|
||||
environment: 'default',
|
||||
featureName: 'first_feature',
|
||||
variants: [],
|
||||
},
|
||||
],
|
||||
contextFields: [context],
|
||||
});
|
||||
});
|
||||
|
||||
test('should export tags', async () => {
|
||||
const featureName = 'first_feature';
|
||||
await createProject('default', 'default');
|
||||
await createToggle(
|
||||
{
|
||||
name: featureName,
|
||||
description: 'the #1 feature',
|
||||
},
|
||||
defaultStrategy,
|
||||
['tag1'],
|
||||
);
|
||||
|
||||
const { body } = await app.request
|
||||
.post('/api/admin/features-batch/export')
|
||||
.send({
|
||||
features: ['first_feature'],
|
||||
environment: 'default',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { name, ...resultStrategy } = defaultStrategy;
|
||||
expect(body).toMatchObject({
|
||||
features: [
|
||||
{
|
||||
name: 'first_feature',
|
||||
},
|
||||
],
|
||||
featureStrategies: [resultStrategy],
|
||||
featureEnvironments: [
|
||||
{
|
||||
enabled: false,
|
||||
environment: 'default',
|
||||
featureName: 'first_feature',
|
||||
variants: [],
|
||||
},
|
||||
],
|
||||
featureTags: [{ featureName, tagValue: 'tag1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns all features, when no feature was defined', async () => {
|
||||
await createProject('default', 'default');
|
||||
await createToggle({
|
||||
@ -225,6 +365,7 @@ test('import features to existing project and environment', async () => {
|
||||
environment: 'irrelevant',
|
||||
},
|
||||
],
|
||||
contextFields: [],
|
||||
},
|
||||
project: project,
|
||||
environment: environment,
|
||||
|
@ -1037,6 +1037,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"exportQuerySchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"downloadFile": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
},
|
||||
@ -1057,12 +1060,30 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"exportResultSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"contextFields": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/contextFieldSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"featureEnvironments": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureEnvironmentSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"featureStrategies": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureStrategySchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"featureTags": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureTagSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"features": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureSchema",
|
||||
|
@ -218,4 +218,13 @@ export default class FakeFeatureEnvironmentStore
|
||||
): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
getAllByFeatures(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
features: string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
environment?: string,
|
||||
): Promise<IFeatureEnvironment[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user