From b895c997430044babaf752c6f950587e81d0e3c6 Mon Sep 17 00:00:00 2001 From: sjaanus Date: Tue, 17 Jan 2023 13:10:20 +0200 Subject: [PATCH] Export features (#2905) --- src/lib/db/feature-environment-store.ts | 16 ++ src/lib/openapi/spec/export-query-schema.ts | 3 + src/lib/openapi/spec/export-result-schema.ts | 24 +++ src/lib/routes/admin-api/export-import.ts | 20 ++- src/lib/services/export-import-service.ts | 89 ++++++++--- src/lib/types/events.ts | 2 + .../types/stores/feature-environment-store.ts | 4 + .../e2e/api/admin/export-import.e2e.test.ts | 141 ++++++++++++++++++ .../__snapshots__/openapi.e2e.test.ts.snap | 21 +++ .../fake-feature-environment-store.ts | 9 ++ 10 files changed, 304 insertions(+), 25 deletions(-) diff --git a/src/lib/db/feature-environment-store.ts b/src/lib/db/feature-environment-store.ts index 0d916ff827..b7ef3261ea 100644 --- a/src/lib/db/feature-environment-store.ts +++ b/src/lib/db/feature-environment-store.ts @@ -108,6 +108,22 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore { })); } + async getAllByFeatures( + features: string[], + environment?: string, + ): Promise { + 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, diff --git a/src/lib/openapi/spec/export-query-schema.ts b/src/lib/openapi/spec/export-query-schema.ts index 6e8ace3294..ec5a858dc4 100644 --- a/src/lib/openapi/spec/export-query-schema.ts +++ b/src/lib/openapi/spec/export-query-schema.ts @@ -16,6 +16,9 @@ export const exportQuerySchema = { environment: { type: 'string', }, + downloadFile: { + type: 'boolean', + }, }, components: { schemas: {}, diff --git a/src/lib/openapi/spec/export-result-schema.ts b/src/lib/openapi/spec/export-result-schema.ts index 0eaaa5f2b1..cb8db1b3ff 100644 --- a/src/lib/openapi/spec/export-result-schema.ts +++ b/src/lib/openapi/spec/export-result-schema.ts @@ -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; diff --git a/src/lib/routes/admin-api/export-import.ts b/src/lib/routes/admin-api/export-import.ts index 34b3e5415e..826400d3cb 100644 --- a/src/lib/routes/admin-api/export-import.ts +++ b/src/lib/routes/admin-api/export-import.ts @@ -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, + req: IAuthRequest, res: Response, ): Promise { 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() { diff --git a/src/lib/services/export-import-service.ts b/src/lib/services/export-import-service.ts index 357a2ae9ab..48f02eb329 100644 --- a/src/lib/services/export-import-service.ts +++ b/src/lib/services/export-import-service.ts @@ -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 { - 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 { + 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 { diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index f36c4d805c..dc4d539770 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -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; diff --git a/src/lib/types/stores/feature-environment-store.ts b/src/lib/types/stores/feature-environment-store.ts index 0a538d0b42..e7435c7145 100644 --- a/src/lib/types/stores/feature-environment-store.ts +++ b/src/lib/types/stores/feature-environment-store.ts @@ -15,6 +15,10 @@ export interface IFeatureEnvironmentStore getEnvironmentsForFeature( featureName: string, ): Promise; + getAllByFeatures( + features: string[], + environment?: string, + ): Promise; isEnvironmentEnabled( featureName: string, environment: string, diff --git a/src/test/e2e/api/admin/export-import.e2e.test.ts b/src/test/e2e/api/admin/export-import.e2e.test.ts index b0b5b91ff3..de35364a2d 100644 --- a/src/test/e2e/api/admin/export-import.e2e.test.ts +++ b/src/test/e2e/api/admin/export-import.e2e.test.ts @@ -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 = 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 => { + 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, diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 2d6409b28f..eec7962586 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", diff --git a/src/test/fixtures/fake-feature-environment-store.ts b/src/test/fixtures/fake-feature-environment-store.ts index 23c782f5ea..f21192e597 100644 --- a/src/test/fixtures/fake-feature-environment-store.ts +++ b/src/test/fixtures/fake-feature-environment-store.ts @@ -218,4 +218,13 @@ export default class FakeFeatureEnvironmentStore ): Promise { 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 { + throw new Error('Method not implemented.'); + } }