Skip to content

Commit

Permalink
Export with strategies (#2877)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Jan 11, 2023
1 parent afdcd45 commit eb7e82d
Show file tree
Hide file tree
Showing 16 changed files with 306 additions and 27 deletions.
15 changes: 15 additions & 0 deletions src/lib/db/feature-strategy-store.ts
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/db/feature-toggle-store.ts
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/lib/openapi/index.ts
Expand Up @@ -33,6 +33,8 @@ import {
environmentsSchema,
eventSchema,
eventsSchema,
exportResultSchema,
exportQuerySchema,
featureEnvironmentMetricsSchema,
featureEnvironmentSchema,
featureEventsSchema,
Expand Down Expand Up @@ -166,6 +168,8 @@ export const schemas = {
environmentsProjectSchema,
eventSchema,
eventsSchema,
exportResultSchema,
exportQuerySchema,
featureEnvironmentMetricsSchema,
featureEnvironmentSchema,
featureEventsSchema,
Expand Down
13 changes: 13 additions & 0 deletions src/lib/openapi/spec/export-query-schema.test.ts
@@ -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 changes: 25 additions & 0 deletions src/lib/openapi/spec/export-query-schema.ts
@@ -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 changes: 22 additions & 0 deletions src/lib/openapi/spec/export-result-schema.test.ts
@@ -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 changes: 32 additions & 0 deletions src/lib/openapi/spec/export-result-schema.ts
@@ -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>;
2 changes: 2 additions & 0 deletions src/lib/openapi/spec/index.ts
Expand Up @@ -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';
35 changes: 21 additions & 14 deletions src/lib/routes/admin-api/export-import.ts
Expand Up @@ -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 {
Expand All @@ -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',
Expand All @@ -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() {
Expand Down
17 changes: 11 additions & 6 deletions src/lib/services/export-import-service.ts
@@ -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';
Expand All @@ -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[];
Expand All @@ -33,6 +34,7 @@ export interface IExportData {
features: FeatureToggle[];
tags?: ITag[];
contextFields?: IContextFieldDto[];
featureStrategies: IFeatureStrategy[];
}

export default class ExportImportService {
Expand Down Expand Up @@ -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> {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types/stores/feature-strategies-store.ts
Expand Up @@ -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[]>;
}
1 change: 1 addition & 0 deletions src/lib/types/stores/feature-toggle-store.ts
Expand Up @@ -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
Expand Down
64 changes: 57 additions & 7 deletions src/test/e2e/api/admin/export-import.e2e.test.ts
Expand Up @@ -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',
) => {
Expand Down Expand Up @@ -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({
Expand All @@ -71,13 +98,36 @@ 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 () => {
Expand Down

0 comments on commit eb7e82d

Please sign in to comment.