1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-17 13:46:47 +02:00

Export with strategies (#2877)

This commit is contained in:
sjaanus 2023-01-11 17:00:20 +02:00 committed by GitHub
parent afdcd45042
commit eb7e82dff2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 306 additions and 27 deletions

View File

@ -201,6 +201,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
return rows.map(mapRow); return rows.map(mapRow);
} }
async getAllByFeatures(
features: string[],
environment?: string,
): Promise<IFeatureStrategy[]> {
const query = this.db
.select(COLUMNS)
.from<IFeatureStrategiesTable>(T.featureStrategies)
.where('environment', environment);
if (features) {
query.whereIn('feature_name', features);
}
const rows = await query;
return rows.map(mapRow);
}
async getStrategiesForFeatureEnv( async getStrategiesForFeatureEnv(
projectId: string, projectId: string,
featureName: string, featureName: string,

View File

@ -102,6 +102,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return rows.map(this.rowToFeature); return rows.map(this.rowToFeature);
} }
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
const query = this.db<FeaturesTable>(TABLE);
if (names.length > 0) {
query.whereIn('name', names);
}
const rows = await query;
return rows.map(this.rowToFeature);
}
/** /**
* Get projectId from feature filtered by name. Used by Rbac middleware * Get projectId from feature filtered by name. Used by Rbac middleware
* @deprecated * @deprecated

View File

@ -33,6 +33,8 @@ import {
environmentsSchema, environmentsSchema,
eventSchema, eventSchema,
eventsSchema, eventsSchema,
exportResultSchema,
exportQuerySchema,
featureEnvironmentMetricsSchema, featureEnvironmentMetricsSchema,
featureEnvironmentSchema, featureEnvironmentSchema,
featureEventsSchema, featureEventsSchema,
@ -166,6 +168,8 @@ export const schemas = {
environmentsProjectSchema, environmentsProjectSchema,
eventSchema, eventSchema,
eventsSchema, eventsSchema,
exportResultSchema,
exportQuerySchema,
featureEnvironmentMetricsSchema, featureEnvironmentMetricsSchema,
featureEnvironmentSchema, featureEnvironmentSchema,
featureEventsSchema, featureEventsSchema,

View File

@ -0,0 +1,13 @@
import { validateSchema } from '../validate';
import { ExportQuerySchema } from './export-query-schema';
test('exportQuerySchema', () => {
const data: ExportQuerySchema = {
environment: 'production',
features: ['firstFeature', 'secondFeature'],
};
expect(
validateSchema('#/components/schemas/exportQuerySchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,25 @@
import { FromSchema } from 'json-schema-to-ts';
export const exportQuerySchema = {
$id: '#/components/schemas/exportQuerySchema',
type: 'object',
additionalProperties: false,
required: ['features', 'environment'],
properties: {
features: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
},
environment: {
type: 'string',
},
},
components: {
schemas: {},
},
} as const;
export type ExportQuerySchema = FromSchema<typeof exportQuerySchema>;

View File

@ -0,0 +1,22 @@
import { validateSchema } from '../validate';
import { ExportResultSchema } from './export-result-schema';
test('exportResultSchema', () => {
const data: ExportResultSchema = {
features: [
{
name: 'test',
},
],
featureStrategies: [
{
name: 'test',
constraints: [],
},
],
};
expect(
validateSchema('#/components/schemas/exportResultSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,32 @@
import { FromSchema } from 'json-schema-to-ts';
import { featureSchema } from './feature-schema';
import { featureStrategySchema } from './feature-strategy-schema';
export const exportResultSchema = {
$id: '#/components/schemas/exportResultSchema',
type: 'object',
additionalProperties: false,
required: ['features', 'featureStrategies'],
properties: {
features: {
type: 'array',
items: {
$ref: '#/components/schemas/featureSchema',
},
},
featureStrategies: {
type: 'array',
items: {
$ref: '#/components/schemas/featureStrategySchema',
},
},
},
components: {
schemas: {
featureSchema,
featureStrategySchema,
},
},
} as const;
export type ExportResultSchema = FromSchema<typeof exportResultSchema>;

View File

@ -122,3 +122,5 @@ export * from './public-signup-token-update-schema';
export * from './feature-environment-metrics-schema'; export * from './feature-environment-metrics-schema';
export * from './requests-per-second-schema'; export * from './requests-per-second-schema';
export * from './requests-per-second-segmented-schema'; export * from './requests-per-second-segmented-schema';
export * from './export-result-schema';
export * from './export-query-schema';

View File

@ -6,10 +6,13 @@ import { IUnleashServices } from '../../types/services';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import ExportImportService, { import ExportImportService, {
IExportQuery,
IImportDTO, IImportDTO,
} from 'lib/services/export-import-service'; } from 'lib/services/export-import-service';
import { InvalidOperationError } from '../../error'; 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 { IAuthRequest } from '../unleash-types';
class ExportImportController extends Controller { class ExportImportController extends Controller {
@ -35,17 +38,16 @@ class ExportImportController extends Controller {
path: '/export', path: '/export',
permission: NONE, permission: NONE,
handler: this.export, handler: this.export,
// middleware: [ middleware: [
// this.openApiService.validPath({ this.openApiService.validPath({
// tags: ['Import/Export'], tags: ['Unstable'],
// operationId: 'export', operationId: 'exportFeatures',
// responses: { requestBody: createRequestSchema('exportQuerySchema'),
// 200: createResponseSchema('stateSchema'), responses: {
// }, 200: createResponseSchema('exportResultSchema'),
// parameters: },
// exportQueryParameters as unknown as OpenAPIV3.ParameterObject[], }),
// }), ],
// ],
}); });
this.route({ this.route({
method: 'post', method: 'post',
@ -56,14 +58,19 @@ class ExportImportController extends Controller {
} }
async export( async export(
req: Request<unknown, unknown, IExportQuery, unknown>, req: Request<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 data = await this.exportImportService.export(query);
res.json(data); this.openApiService.respondWithValidation(
200,
res,
exportResultSchema.$id,
serializeDates(data),
);
} }
private verifyExportImportEnabled() { private verifyExportImportEnabled() {

View File

@ -1,5 +1,5 @@
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { FeatureToggle, ITag } from '../types/model'; import { FeatureToggle, IFeatureStrategy, ITag } from '../types/model';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { IFeatureTagStore } from '../types/stores/feature-tag-store'; import { IFeatureTagStore } from '../types/stores/feature-tag-store';
import { IProjectStore } from '../types/stores/project-store'; import { IProjectStore } from '../types/stores/project-store';
@ -17,6 +17,7 @@ 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';
export interface IExportQuery { export interface IExportQuery {
features: string[]; features: string[];
@ -33,6 +34,7 @@ export interface IExportData {
features: FeatureToggle[]; features: FeatureToggle[];
tags?: ITag[]; tags?: ITag[];
contextFields?: IContextFieldDto[]; contextFields?: IContextFieldDto[];
featureStrategies: IFeatureStrategy[];
} }
export default class ExportImportService { export default class ExportImportService {
@ -90,11 +92,14 @@ export default class ExportImportService {
this.logger = getLogger('services/state-service.js'); this.logger = getLogger('services/state-service.js');
} }
async export(query: IExportQuery): Promise<IExportData> { async export(query: ExportQuerySchema): Promise<IExportData> {
const features = ( const features = await this.toggleStore.getAllByNames(query.features);
await this.toggleStore.getAll({ archived: false }) const featureStrategies =
).filter((toggle) => query.features.includes(toggle.name)); await this.featureStrategiesStore.getAllByFeatures(
return { features: features }; query.features,
query.environment,
);
return { features, featureStrategies };
} }
async import(dto: IImportDTO, user: User): Promise<void> { async import(dto: IImportDTO, user: User): Promise<void> {

View File

@ -59,4 +59,8 @@ export interface IFeatureStrategiesStore
): Promise<void>; ): Promise<void>;
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>; getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
updateSortOrder(id: string, sortOrder: number): Promise<void>; updateSortOrder(id: string, sortOrder: number): Promise<void>;
getAllByFeatures(
features: string[],
environment?: string,
): Promise<IFeatureStrategy[]>;
} }

View File

@ -16,6 +16,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
archive(featureName: string): Promise<FeatureToggle>; archive(featureName: string): Promise<FeatureToggle>;
revive(featureName: string): Promise<FeatureToggle>; revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>; getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
/** /**
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments) * @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName * @param featureName

View File

@ -12,9 +12,15 @@ let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
let eventStore: IEventStore; let eventStore: IEventStore;
const defaultStrategy = {
name: 'default',
parameters: {},
constraints: [],
};
const createToggle = async ( const createToggle = async (
toggle: FeatureToggleDTO, toggle: FeatureToggleDTO,
strategy?: Omit<IStrategyConfig, 'id'>, strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
projectId: string = 'default', projectId: string = 'default',
username: string = 'test', username: string = 'test',
) => { ) => {
@ -53,15 +59,36 @@ afterAll(async () => {
await db.destroy(); await db.destroy();
}); });
afterEach(() => { afterEach(async () => {
db.stores.featureToggleStore.deleteAll(); await db.stores.featureToggleStore.deleteAll();
}); });
test('exports features', async () => { test('exports features', async () => {
await createToggle({ const strategy = {
name: 'first_feature', name: 'default',
description: 'the #1 feature', parameters: { rollout: '100', stickiness: 'default' },
}); constraints: [
{
contextName: 'appName',
values: ['test'],
operator: 'IN' as const,
},
],
};
await createToggle(
{
name: 'first_feature',
description: 'the #1 feature',
},
strategy,
);
await createToggle(
{
name: 'second_feature',
description: 'the #1 feature',
},
strategy,
);
const { body } = await app.request const { body } = await app.request
.post('/api/admin/features-batch/export') .post('/api/admin/features-batch/export')
.send({ .send({
@ -71,15 +98,38 @@ test('exports features', async () => {
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(200); .expect(200);
const { name, ...resultStrategy } = strategy;
expect(body).toMatchObject({ expect(body).toMatchObject({
features: [ features: [
{ {
name: 'first_feature', name: 'first_feature',
}, },
], ],
featureStrategies: [resultStrategy],
}); });
}); });
test('returns all features, when no feature was defined', async () => {
await createToggle({
name: 'first_feature',
description: 'the #1 feature',
});
await createToggle({
name: 'second_feature',
description: 'the #1 feature',
});
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
features: [],
environment: 'default',
})
.set('Content-Type', 'application/json')
.expect(200);
expect(body.features).toHaveLength(2);
});
test('import features', async () => { test('import features', async () => {
const feature: FeatureToggle = { project: 'ignore', name: 'first_feature' }; const feature: FeatureToggle = { project: 'ignore', name: 'first_feature' };
await app.request await app.request

View File

@ -1034,6 +1034,48 @@ exports[`should serve the OpenAPI spec 1`] = `
], ],
"type": "object", "type": "object",
}, },
"exportQuerySchema": {
"additionalProperties": false,
"properties": {
"environment": {
"type": "string",
},
"features": {
"items": {
"minLength": 1,
"type": "string",
},
"type": "array",
},
},
"required": [
"features",
"environment",
],
"type": "object",
},
"exportResultSchema": {
"additionalProperties": false,
"properties": {
"featureStrategies": {
"items": {
"$ref": "#/components/schemas/featureStrategySchema",
},
"type": "array",
},
"features": {
"items": {
"$ref": "#/components/schemas/featureSchema",
},
"type": "array",
},
},
"required": [
"features",
"featureStrategies",
],
"type": "object",
},
"featureEnvironmentMetricsSchema": { "featureEnvironmentMetricsSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -4607,6 +4649,37 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/features-batch/export": {
"post": {
"operationId": "exportFeatures",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/exportQuerySchema",
},
},
},
"description": "exportQuerySchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/exportResultSchema",
},
},
},
"description": "exportResultSchema",
},
},
"tags": [
"Unstable",
],
},
},
"/api/admin/features/validate": { "/api/admin/features/validate": {
"post": { "post": {
"operationId": "validateFeature", "operationId": "validateFeature",

View File

@ -310,6 +310,19 @@ export default class FakeFeatureStrategiesStore
): Promise<IFeatureOverview[]> { ): Promise<IFeatureOverview[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }
getAllByFeatures(
features: string[],
environment?: string,
): Promise<IFeatureStrategy[]> {
return Promise.resolve(
this.featureStrategies.filter(
(strategy) =>
features.includes(strategy.featureName) &&
strategy.environment === environment,
),
);
}
} }
module.exports = FakeFeatureStrategiesStore; module.exports = FakeFeatureStrategiesStore;

View File

@ -28,6 +28,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.features.filter(this.getFilterQuery(query)).length; return this.features.filter(this.getFilterQuery(query)).length;
} }
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
return this.features.filter((f) => names.includes(f.name));
}
async getProjectId(name: string): Promise<string> { async getProjectId(name: string): Promise<string> {
return this.get(name).then((f) => f.project); return this.get(name).then((f) => f.project);
} }