1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +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(
featureName: string,
environment: string,

View File

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

View File

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

View File

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

View File

@ -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([
this.toggleStore.getAllByNames(query.features),
(
await this.featureEnvironmentStore.getAll({
environment: query.environment,
})
).filter((item) => query.features.includes(item.featureName)),
this.featureStrategiesStore.getAllByFeatures(
query.features,
query.environment,
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.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> {

View File

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

View File

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

View File

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

View File

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

View File

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