diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index c495b4dd29..2d69e165d8 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -201,6 +201,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { return rows.map(mapRow); } + async getAllByFeatures( + features: string[], + environment?: string, + ): Promise { + const query = this.db + .select(COLUMNS) + .from(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, diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 9becf37c61..840fee69c0 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -102,6 +102,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return rows.map(this.rowToFeature); } + async getAllByNames(names: string[]): Promise { + const query = this.db(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 diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 8b635cc3de..90c46eef3e 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/export-query-schema.test.ts b/src/lib/openapi/spec/export-query-schema.test.ts new file mode 100644 index 0000000000..386d8753f1 --- /dev/null +++ b/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(); +}); diff --git a/src/lib/openapi/spec/export-query-schema.ts b/src/lib/openapi/spec/export-query-schema.ts new file mode 100644 index 0000000000..6e8ace3294 --- /dev/null +++ b/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; diff --git a/src/lib/openapi/spec/export-result-schema.test.ts b/src/lib/openapi/spec/export-result-schema.test.ts new file mode 100644 index 0000000000..392d6e4db5 --- /dev/null +++ b/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(); +}); diff --git a/src/lib/openapi/spec/export-result-schema.ts b/src/lib/openapi/spec/export-result-schema.ts new file mode 100644 index 0000000000..0eaaa5f2b1 --- /dev/null +++ b/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; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index ef4ab71ea9..23378a128c 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/routes/admin-api/export-import.ts b/src/lib/routes/admin-api/export-import.ts index cde5d40ef5..34b3e5415e 100644 --- a/src/lib/routes/admin-api/export-import.ts +++ b/src/lib/routes/admin-api/export-import.ts @@ -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, + req: Request, res: Response, ): Promise { 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() { diff --git a/src/lib/services/export-import-service.ts b/src/lib/services/export-import-service.ts index d11f57413f..adbaf5521e 100644 --- a/src/lib/services/export-import-service.ts +++ b/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'; @@ -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 { - const features = ( - await this.toggleStore.getAll({ archived: false }) - ).filter((toggle) => query.features.includes(toggle.name)); - return { features: features }; + async export(query: ExportQuerySchema): Promise { + 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 { diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 3cd124b8b2..a5aae8aa27 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -59,4 +59,8 @@ export interface IFeatureStrategiesStore ): Promise; getStrategiesBySegment(segmentId: number): Promise; updateSortOrder(id: string, sortOrder: number): Promise; + getAllByFeatures( + features: string[], + environment?: string, + ): Promise; } diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index b4eaef28c2..5419395aa2 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -16,6 +16,7 @@ export interface IFeatureToggleStore extends Store { archive(featureName: string): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; + getAllByNames(names: string[]): Promise; /** * @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments) * @param featureName 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 ceefdf1a58..6b00620e53 100644 --- a/src/test/e2e/api/admin/export-import.e2e.test.ts +++ b/src/test/e2e/api/admin/export-import.e2e.test.ts @@ -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, + strategy: Omit = 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 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 9485c181d0..2d6409b28f 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 @@ -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", diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index 0f7f5a20f3..da16a6527c 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -310,6 +310,19 @@ export default class FakeFeatureStrategiesStore ): Promise { return Promise.resolve([]); } + + getAllByFeatures( + features: string[], + environment?: string, + ): Promise { + return Promise.resolve( + this.featureStrategies.filter( + (strategy) => + features.includes(strategy.featureName) && + strategy.environment === environment, + ), + ); + } } module.exports = FakeFeatureStrategiesStore; diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index 3e2c883aec..1c35568057 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -28,6 +28,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return this.features.filter(this.getFilterQuery(query)).length; } + async getAllByNames(names: string[]): Promise { + return this.features.filter((f) => names.includes(f.name)); + } + async getProjectId(name: string): Promise { return this.get(name).then((f) => f.project); }