mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
Export with strategies (#2877)
This commit is contained in:
parent
afdcd45042
commit
eb7e82dff2
@ -201,6 +201,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
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(
|
||||
projectId: string,
|
||||
featureName: string,
|
||||
|
@ -102,6 +102,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
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
|
||||
* @deprecated
|
||||
|
@ -33,6 +33,8 @@ import {
|
||||
environmentsSchema,
|
||||
eventSchema,
|
||||
eventsSchema,
|
||||
exportResultSchema,
|
||||
exportQuerySchema,
|
||||
featureEnvironmentMetricsSchema,
|
||||
featureEnvironmentSchema,
|
||||
featureEventsSchema,
|
||||
@ -166,6 +168,8 @@ export const schemas = {
|
||||
environmentsProjectSchema,
|
||||
eventSchema,
|
||||
eventsSchema,
|
||||
exportResultSchema,
|
||||
exportQuerySchema,
|
||||
featureEnvironmentMetricsSchema,
|
||||
featureEnvironmentSchema,
|
||||
featureEventsSchema,
|
||||
|
13
src/lib/openapi/spec/export-query-schema.test.ts
Normal file
13
src/lib/openapi/spec/export-query-schema.test.ts
Normal 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();
|
||||
});
|
25
src/lib/openapi/spec/export-query-schema.ts
Normal file
25
src/lib/openapi/spec/export-query-schema.ts
Normal 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>;
|
22
src/lib/openapi/spec/export-result-schema.test.ts
Normal file
22
src/lib/openapi/spec/export-result-schema.test.ts
Normal 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();
|
||||
});
|
32
src/lib/openapi/spec/export-result-schema.ts
Normal file
32
src/lib/openapi/spec/export-result-schema.ts
Normal 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>;
|
@ -122,3 +122,5 @@ export * from './public-signup-token-update-schema';
|
||||
export * from './feature-environment-metrics-schema';
|
||||
export * from './requests-per-second-schema';
|
||||
export * from './requests-per-second-segmented-schema';
|
||||
export * from './export-result-schema';
|
||||
export * from './export-query-schema';
|
||||
|
@ -6,10 +6,13 @@ import { IUnleashServices } from '../../types/services';
|
||||
import { Logger } from '../../logger';
|
||||
import { OpenApiService } from '../../services/openapi-service';
|
||||
import ExportImportService, {
|
||||
IExportQuery,
|
||||
IImportDTO,
|
||||
} 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';
|
||||
|
||||
class ExportImportController extends Controller {
|
||||
@ -35,17 +38,16 @@ class ExportImportController extends Controller {
|
||||
path: '/export',
|
||||
permission: NONE,
|
||||
handler: this.export,
|
||||
// middleware: [
|
||||
// this.openApiService.validPath({
|
||||
// tags: ['Import/Export'],
|
||||
// operationId: 'export',
|
||||
// responses: {
|
||||
// 200: createResponseSchema('stateSchema'),
|
||||
// },
|
||||
// parameters:
|
||||
// exportQueryParameters as unknown as OpenAPIV3.ParameterObject[],
|
||||
// }),
|
||||
// ],
|
||||
middleware: [
|
||||
this.openApiService.validPath({
|
||||
tags: ['Unstable'],
|
||||
operationId: 'exportFeatures',
|
||||
requestBody: createRequestSchema('exportQuerySchema'),
|
||||
responses: {
|
||||
200: createResponseSchema('exportResultSchema'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
this.route({
|
||||
method: 'post',
|
||||
@ -56,14 +58,19 @@ class ExportImportController extends Controller {
|
||||
}
|
||||
|
||||
async export(
|
||||
req: Request<unknown, unknown, IExportQuery, unknown>,
|
||||
req: Request<unknown, unknown, ExportQuerySchema, unknown>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
this.verifyExportImportEnabled();
|
||||
const query = req.body;
|
||||
const data = await this.exportImportService.export(query);
|
||||
|
||||
res.json(data);
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
exportResultSchema.$id,
|
||||
serializeDates(data),
|
||||
);
|
||||
}
|
||||
|
||||
private verifyExportImportEnabled() {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { FeatureToggle, ITag } from '../types/model';
|
||||
import { FeatureToggle, IFeatureStrategy, ITag } from '../types/model';
|
||||
import { Logger } from '../logger';
|
||||
import { IFeatureTagStore } from '../types/stores/feature-tag-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 FeatureToggleService from './feature-toggle-service';
|
||||
import User from 'lib/types/user';
|
||||
import { ExportQuerySchema } from '../openapi/spec/export-query-schema';
|
||||
|
||||
export interface IExportQuery {
|
||||
features: string[];
|
||||
@ -33,6 +34,7 @@ export interface IExportData {
|
||||
features: FeatureToggle[];
|
||||
tags?: ITag[];
|
||||
contextFields?: IContextFieldDto[];
|
||||
featureStrategies: IFeatureStrategy[];
|
||||
}
|
||||
|
||||
export default class ExportImportService {
|
||||
@ -90,11 +92,14 @@ export default class ExportImportService {
|
||||
this.logger = getLogger('services/state-service.js');
|
||||
}
|
||||
|
||||
async export(query: IExportQuery): Promise<IExportData> {
|
||||
const features = (
|
||||
await this.toggleStore.getAll({ archived: false })
|
||||
).filter((toggle) => query.features.includes(toggle.name));
|
||||
return { features: features };
|
||||
async export(query: ExportQuerySchema): Promise<IExportData> {
|
||||
const features = await this.toggleStore.getAllByNames(query.features);
|
||||
const featureStrategies =
|
||||
await this.featureStrategiesStore.getAllByFeatures(
|
||||
query.features,
|
||||
query.environment,
|
||||
);
|
||||
return { features, featureStrategies };
|
||||
}
|
||||
|
||||
async import(dto: IImportDTO, user: User): Promise<void> {
|
||||
|
@ -59,4 +59,8 @@ export interface IFeatureStrategiesStore
|
||||
): Promise<void>;
|
||||
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
|
||||
updateSortOrder(id: string, sortOrder: number): Promise<void>;
|
||||
getAllByFeatures(
|
||||
features: string[],
|
||||
environment?: string,
|
||||
): Promise<IFeatureStrategy[]>;
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
archive(featureName: string): Promise<FeatureToggle>;
|
||||
revive(featureName: string): 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)
|
||||
* @param featureName
|
||||
|
@ -12,9 +12,15 @@ let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
let eventStore: IEventStore;
|
||||
|
||||
const defaultStrategy = {
|
||||
name: 'default',
|
||||
parameters: {},
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
const createToggle = async (
|
||||
toggle: FeatureToggleDTO,
|
||||
strategy?: Omit<IStrategyConfig, 'id'>,
|
||||
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
|
||||
projectId: string = 'default',
|
||||
username: string = 'test',
|
||||
) => {
|
||||
@ -53,15 +59,36 @@ afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.stores.featureToggleStore.deleteAll();
|
||||
afterEach(async () => {
|
||||
await db.stores.featureToggleStore.deleteAll();
|
||||
});
|
||||
|
||||
test('exports features', async () => {
|
||||
await createToggle({
|
||||
name: 'first_feature',
|
||||
description: 'the #1 feature',
|
||||
});
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
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
|
||||
.post('/api/admin/features-batch/export')
|
||||
.send({
|
||||
@ -71,15 +98,38 @@ test('exports features', async () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { name, ...resultStrategy } = strategy;
|
||||
expect(body).toMatchObject({
|
||||
features: [
|
||||
{
|
||||
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 () => {
|
||||
const feature: FeatureToggle = { project: 'ignore', name: 'first_feature' };
|
||||
await app.request
|
||||
|
@ -1034,6 +1034,48 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"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": {
|
||||
"post": {
|
||||
"operationId": "validateFeature",
|
||||
|
@ -310,6 +310,19 @@ export default class FakeFeatureStrategiesStore
|
||||
): Promise<IFeatureOverview[]> {
|
||||
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;
|
||||
|
@ -28,6 +28,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
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> {
|
||||
return this.get(name).then((f) => f.project);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user