1
0
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:
sjaanus 2023-01-17 13:10:20 +02:00 committed by GitHub
parent 1a410a4ed5
commit b895c99743
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 304 additions and 25 deletions

View File

@ -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( async disableEnvironmentIfNoStrategies(
featureName: string, featureName: string,
environment: string, environment: string,

View File

@ -16,6 +16,9 @@ export const exportQuerySchema = {
environment: { environment: {
type: 'string', type: 'string',
}, },
downloadFile: {
type: 'boolean',
},
}, },
components: { components: {
schemas: {}, schemas: {},

View File

@ -1,6 +1,9 @@
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { featureSchema } from './feature-schema'; import { featureSchema } from './feature-schema';
import { featureStrategySchema } from './feature-strategy-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 = { export const exportResultSchema = {
$id: '#/components/schemas/exportResultSchema', $id: '#/components/schemas/exportResultSchema',
@ -20,11 +23,32 @@ export const exportResultSchema = {
$ref: '#/components/schemas/featureStrategySchema', $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: { components: {
schemas: { schemas: {
featureSchema, featureSchema,
featureStrategySchema, featureStrategySchema,
featureEnvironmentSchema,
contextFieldSchema,
featureTagSchema,
}, },
}, },
} as const; } as const;

View File

@ -1,4 +1,4 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { NONE } from '../../types/permissions'; import { NONE } from '../../types/permissions';
import { IUnleashConfig } from '../../types/option'; 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 { ExportQuerySchema } from '../../openapi/spec/export-query-schema';
import { serializeDates } from '../../types'; import { serializeDates } from '../../types';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import { format as formatDate } from 'date-fns';
import { extractUsername } from '../../util';
class ExportImportController extends Controller { class ExportImportController extends Controller {
private logger: Logger; private logger: Logger;
@ -58,12 +60,13 @@ class ExportImportController extends Controller {
} }
async export( async export(
req: Request<unknown, unknown, ExportQuerySchema, unknown>, req: IAuthRequest<unknown, unknown, ExportQuerySchema, unknown>,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
this.verifyExportImportEnabled(); this.verifyExportImportEnabled();
const query = req.body; 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( this.openApiService.respondWithValidation(
200, 200,
@ -71,6 +74,17 @@ class ExportImportController extends Controller {
exportResultSchema.$id, exportResultSchema.$id,
serializeDates(data), 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() { private verifyExportImportEnabled() {

View File

@ -3,6 +3,7 @@ import {
FeatureToggle, FeatureToggle,
IFeatureEnvironment, IFeatureEnvironment,
IFeatureStrategy, IFeatureStrategy,
IFeatureStrategySegment,
ITag, ITag,
} from '../types/model'; } from '../types/model';
import { Logger } from '../logger'; 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 { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { IEnvironmentStore } from '../types/stores/environment-store'; import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-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 { ISegmentStore } from '../types/stores/segment-store';
import { IFlagResolver, IUnleashServices } from 'lib/types';
import { IContextFieldDto } from '../types/stores/context-field-store'; import { IContextFieldDto } from '../types/stores/context-field-store';
import FeatureToggleService from './feature-toggle-service'; import FeatureToggleService from './feature-toggle-service';
import User from 'lib/types/user'; import User from 'lib/types/user';
import { ExportQuerySchema } from '../openapi/spec/export-query-schema'; import { ExportQuerySchema } from '../openapi/spec/export-query-schema';
import { FEATURES_EXPORTED, IFlagResolver, IUnleashServices } from '../types';
export interface IExportQuery {
features: string[];
environment: string;
}
export interface IImportDTO { export interface IImportDTO {
data: IExportData; data: IExportData;
@ -38,7 +34,7 @@ export interface IImportDTO {
export interface IExportData { export interface IExportData {
features: FeatureToggle[]; features: FeatureToggle[];
tags?: ITag[]; tags?: ITag[];
contextFields?: IContextFieldDto[]; contextFields: IContextFieldDto[];
featureStrategies: IFeatureStrategy[]; featureStrategies: IFeatureStrategy[];
featureEnvironments: IFeatureEnvironment[]; featureEnvironments: IFeatureEnvironment[];
} }
@ -72,6 +68,8 @@ export default class ExportImportService {
private featureToggleService: FeatureToggleService; private featureToggleService: FeatureToggleService;
private contextFieldStore: IContextFieldStore;
constructor( constructor(
stores: IUnleashStores, stores: IUnleashStores,
{ {
@ -95,24 +93,71 @@ export default class ExportImportService {
this.segmentStore = stores.segmentStore; this.segmentStore = stores.segmentStore;
this.flagResolver = flagResolver; this.flagResolver = flagResolver;
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.contextFieldStore = stores.contextFieldStore;
this.logger = getLogger('services/state-service.js'); this.logger = getLogger('services/state-service.js');
} }
async export(query: ExportQuerySchema): Promise<IExportData> { async export(
const [features, featureEnvironments, featureStrategies] = query: ExportQuerySchema,
await Promise.all([ userName: string,
this.toggleStore.getAllByNames(query.features), ): Promise<IExportData> {
( const [
await this.featureEnvironmentStore.getAll({ features,
environment: query.environment, featureEnvironments,
}) featureStrategies,
).filter((item) => query.features.includes(item.featureName)), strategySegments,
this.featureStrategiesStore.getAllByFeatures( contextFields,
query.features, featureTags,
query.environment, ] = 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.getAll(),
]);
this.addSegmentsToStrategies(featureStrategies, strategySegments);
const filteredContextFields = contextFields.filter((field) =>
featureStrategies.some((strategy) =>
strategy.constraints.some(
(constraint) => constraint.contextName === field.name,
), ),
]); ),
return { features, featureStrategies, featureEnvironments }; );
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> { async import(dto: IImportDTO, user: User): Promise<void> {

View File

@ -104,6 +104,8 @@ export const FEATURE_UNFAVORITED = 'feature-unfavorited';
export const PROJECT_FAVORITED = 'project-favorited'; export const PROJECT_FAVORITED = 'project-favorited';
export const PROJECT_UNFAVORITED = 'project-unfavorited'; export const PROJECT_UNFAVORITED = 'project-unfavorited';
export const FEATURES_EXPORTED = 'features-exported';
export interface IBaseEvent { export interface IBaseEvent {
type: string; type: string;
createdBy: string; createdBy: string;

View File

@ -15,6 +15,10 @@ export interface IFeatureEnvironmentStore
getEnvironmentsForFeature( getEnvironmentsForFeature(
featureName: string, featureName: string,
): Promise<IFeatureEnvironment[]>; ): Promise<IFeatureEnvironment[]>;
getAllByFeatures(
features: string[],
environment?: string,
): Promise<IFeatureEnvironment[]>;
isEnvironmentEnabled( isEnvironmentEnabled(
featureName: string, featureName: string,
environment: string, environment: string,

View File

@ -12,10 +12,12 @@ import {
IFeatureStrategy, IFeatureStrategy,
IFeatureToggleStore, IFeatureToggleStore,
IProjectStore, IProjectStore,
ISegment,
IStrategyConfig, IStrategyConfig,
} from 'lib/types'; } from 'lib/types';
import { DEFAULT_ENV } from '../../../../lib/util'; import { DEFAULT_ENV } from '../../../../lib/util';
import { IImportDTO } from '../../../../lib/services/export-import-service'; import { IImportDTO } from '../../../../lib/services/export-import-service';
import { ContextFieldSchema } from '../../../../lib/openapi';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
@ -30,9 +32,19 @@ const defaultStrategy: IStrategyConfig = {
constraints: [], constraints: [],
}; };
const defaultContext: ContextFieldSchema = {
name: 'region',
description: 'A region',
legalValues: [
{ value: 'north' },
{ value: 'south', description: 'south-desc' },
],
};
const createToggle = async ( const createToggle = async (
toggle: FeatureToggleDTO, toggle: FeatureToggleDTO,
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy, strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
tags: string[] = [],
projectId: string = 'default', projectId: string = 'default',
username: string = 'test', username: string = 'test',
) => { ) => {
@ -48,6 +60,26 @@ const createToggle = async (
username, 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) => { const createProject = async (project: string, environment: string) => {
@ -68,6 +100,12 @@ const createProject = async (project: string, environment: string) => {
.expect(200); .expect(200);
}; };
const createSegment = (postData: object): Promise<ISegment> => {
return app.services.segmentService.create(postData, {
email: 'test@example.com',
});
};
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('export_import_api_serial', getLogger); db = await dbInit('export_import_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, { app = await setupAppWithCustomConfig(db.stores, {
@ -97,6 +135,7 @@ afterAll(async () => {
test('exports features', async () => { test('exports features', async () => {
await createProject('default', 'default'); await createProject('default', 'default');
const segment = await createSegment({ name: 'S3', constraints: [] });
const strategy = { const strategy = {
name: 'default', name: 'default',
parameters: { rollout: '100', stickiness: 'default' }, parameters: { rollout: '100', stickiness: 'default' },
@ -107,6 +146,7 @@ test('exports features', async () => {
operator: 'IN' as const, operator: 'IN' as const,
}, },
], ],
segments: [segment.id],
}; };
await createToggle( 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 () => { test('returns all features, when no feature was defined', async () => {
await createProject('default', 'default'); await createProject('default', 'default');
await createToggle({ await createToggle({
@ -225,6 +365,7 @@ test('import features to existing project and environment', async () => {
environment: 'irrelevant', environment: 'irrelevant',
}, },
], ],
contextFields: [],
}, },
project: project, project: project,
environment: environment, environment: environment,

View File

@ -1037,6 +1037,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"exportQuerySchema": { "exportQuerySchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"downloadFile": {
"type": "boolean",
},
"environment": { "environment": {
"type": "string", "type": "string",
}, },
@ -1057,12 +1060,30 @@ exports[`should serve the OpenAPI spec 1`] = `
"exportResultSchema": { "exportResultSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"contextFields": {
"items": {
"$ref": "#/components/schemas/contextFieldSchema",
},
"type": "array",
},
"featureEnvironments": {
"items": {
"$ref": "#/components/schemas/featureEnvironmentSchema",
},
"type": "array",
},
"featureStrategies": { "featureStrategies": {
"items": { "items": {
"$ref": "#/components/schemas/featureStrategySchema", "$ref": "#/components/schemas/featureStrategySchema",
}, },
"type": "array", "type": "array",
}, },
"featureTags": {
"items": {
"$ref": "#/components/schemas/featureTagSchema",
},
"type": "array",
},
"features": { "features": {
"items": { "items": {
"$ref": "#/components/schemas/featureSchema", "$ref": "#/components/schemas/featureSchema",

View File

@ -218,4 +218,13 @@ export default class FakeFeatureEnvironmentStore
): Promise<void> { ): Promise<void> {
throw new Error('Method not implemented.'); 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.');
}
} }