1
0
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:
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);
}
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,

View File

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

View File

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

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 './requests-per-second-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 { 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() {

View File

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

View File

@ -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[]>;
}

View File

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

View File

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

View File

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

View File

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

View File

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