diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts deleted file mode 100644 index 49919fd801..0000000000 --- a/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import dbInit, { - type ITestDb, -} from '../../../../test/e2e/helpers/database-init'; -import { - type IUnleashTest, - setupAppWithCustomConfig, -} from '../../../../test/e2e/helpers/test-helper'; -import getLogger from '../../../../test/fixtures/no-logger'; -import { DEFAULT_ENV } from '../../../util/constants'; -import { DELTA_EVENT_TYPES } from './client-feature-toggle-delta-types'; - -let app: IUnleashTest; -let db: ITestDb; - -const setupFeatures = async ( - db: ITestDb, - app: IUnleashTest, - project = 'default', -) => { - await app.createFeature('test1', project); - await app.createFeature('test2', project); - - await app.addStrategyToFeatureEnv( - { - name: 'flexibleRollout', - constraints: [], - parameters: { - rollout: '100', - stickiness: 'default', - groupId: 'test1', - }, - }, - DEFAULT_ENV, - 'test1', - project, - ); - await app.addStrategyToFeatureEnv( - { - name: 'default', - constraints: [ - { - contextName: 'userId', - operator: 'IN', - values: ['123'], - }, - ], - parameters: {}, - }, - DEFAULT_ENV, - 'test2', - project, - ); -}; - -beforeAll(async () => { - db = await dbInit('client_feature_toggles_delta', getLogger); - app = await setupAppWithCustomConfig( - db.stores, - { - experimental: { - flags: { - strictSchemaValidation: true, - deltaApi: true, - }, - }, - }, - db.rawDatabase, - ); -}); - -beforeEach(async () => { - await db.stores.eventStore.deleteAll(); - await db.stores.featureToggleStore.deleteAll(); - // @ts-ignore - app.services.clientFeatureToggleService.clientFeatureToggleDelta.resetDelta(); -}); - -afterAll(async () => { - await app.destroy(); - await db.destroy(); -}); - -test('should match with /api/client/delta', async () => { - await setupFeatures(db, app); - - const { body } = await app.request - .get('/api/client/features') - .expect('Content-Type', /json/) - .expect(200); - - const { body: deltaBody } = await app.request - .get('/api/client/delta') - .expect('Content-Type', /json/) - .expect(200); - - expect(body.features).toMatchObject(deltaBody.events[0].features); -}); - -test('should get 304 if asked for latest revision', async () => { - await setupFeatures(db, app); - - const { body, headers } = await app.request - .get('/api/client/delta') - .expect(200); - const etag = headers.etag; - - await app.request - .set('If-None-Match', etag) - .get('/api/client/delta') - .expect(304); -}); - -test('should return correct delta after feature created', async () => { - await app.createFeature('base_feature'); - await syncRevisions(); - const { body, headers } = await app.request - .set('If-None-Match', null) - .get('/api/client/delta') - .expect(200); - const etag = headers.etag; - - expect(body).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.HYDRATION, - features: [ - { - name: 'base_feature', - }, - ], - }, - ], - }); - - await app.createFeature('new_feature'); - - await syncRevisions(); - - const { body: deltaBody } = await app.request - .get('/api/client/delta') - .set('If-None-Match', etag) - .expect(200); - - expect(deltaBody).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: { - name: 'new_feature', - }, - }, - ], - }); -}); - -const syncRevisions = async () => { - await app.services.configurationRevisionService.updateMaxRevisionId(false); - //@ts-ignore - await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent(); -}; - -test('archived features should not be returned as updated', async () => { - await app.createFeature('base_feature'); - await syncRevisions(); - const { body, headers } = await app.request - .get('/api/client/delta') - .expect(200); - const etag = headers.etag; - - expect(body).toMatchObject({ - events: [ - { - features: [ - { - name: 'base_feature', - }, - ], - }, - ], - }); - - await app.archiveFeature('base_feature'); - await syncRevisions(); - await app.createFeature('new_feature'); - await syncRevisions(); - await app.getProjectFeatures('new_feature'); // TODO: this is silly, but events syncing and tests do not work nicely. this is basically a setTimeout - - const { body: deltaBody } = await app.request - .get('/api/client/delta') - .set('If-None-Match', etag) - .expect(200); - - expect(deltaBody).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.FEATURE_REMOVED, - featureName: 'base_feature', - }, - { - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: { - name: 'new_feature', - }, - }, - ], - }); -}); - -test('should get segment updated and removed events', async () => { - await app.createFeature('base_feature'); - await syncRevisions(); - const { body, headers } = await app.request - .get('/api/client/delta') - .expect(200); - const etag = headers.etag; - - expect(body).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.HYDRATION, - features: [ - { - name: 'base_feature', - }, - ], - }, - ], - }); - - const { body: segmentBody } = await app.createSegment({ - name: 'my_segment_a', - constraints: [], - }); - // we need this, because revision service does not fire event for segment creation - await app.createFeature('not_important1'); - await syncRevisions(); - await app.updateSegment(segmentBody.id, { - name: 'a', - constraints: [], - }); - await syncRevisions(); - await app.deleteSegment(segmentBody.id); - // we need this, because revision service does not fire event for segment deletion - await app.createFeature('not_important2'); - await syncRevisions(); - - const { body: deltaBody } = await app.request - .get('/api/client/delta') - .set('If-None-Match', etag) - .expect(200); - - expect(deltaBody).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - }, - { - type: DELTA_EVENT_TYPES.SEGMENT_UPDATED, - }, - - { - type: DELTA_EVENT_TYPES.SEGMENT_UPDATED, - }, - { - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - }, - { - type: DELTA_EVENT_TYPES.SEGMENT_REMOVED, - }, - ], - }); -}); - -test('should return hydration if revision not in cache', async () => { - await app.createFeature('base_feature'); - await syncRevisions(); - const { body, headers } = await app.request - .get('/api/client/delta') - .expect(200); - const etag = headers.etag; - - expect(body).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.HYDRATION, - features: [ - { - name: 'base_feature', - }, - ], - }, - ], - }); - - await app.createFeature('not_important1'); - await syncRevisions(); - - const { body: deltaBody } = await app.request - .get('/api/client/delta') - .set('If-None-Match', etag) - .expect(200); - - expect(deltaBody).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - }, - ], - }); - - const { body: rehydrationBody } = await app.request - .get('/api/client/delta') - .set('If-None-Match', '1') - .expect(200); - - expect(rehydrationBody).toMatchObject({ - events: [ - { - type: DELTA_EVENT_TYPES.HYDRATION, - }, - ], - }); -}); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts deleted file mode 100644 index 74760aee54..0000000000 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Response } from 'express'; -import Controller from '../../../routes/controller'; -import type { - IFlagResolver, - IUnleashConfig, - IUnleashServices, -} from '../../../types'; -import type { Logger } from '../../../logger'; -import { querySchema } from '../../../schema/feature-schema'; -import type { IFeatureToggleQuery } from '../../../types/model'; -import NotFoundError from '../../../error/notfound-error'; -import type { IAuthRequest } from '../../../routes/unleash-types'; -import ApiUser from '../../../types/api-user'; -import { ALL, isAllProjects } from '../../../types/models/api-token'; -import type { ClientSpecService } from '../../../services/client-spec-service'; -import type { OpenApiService } from '../../../services/openapi-service'; -import { NONE } from '../../../types/permissions'; -import { createResponseSchema } from '../../../openapi/util/create-response-schema'; -import type { ClientFeatureToggleService } from '../client-feature-toggle-service'; -import { - type ClientFeaturesDeltaSchema, - clientFeaturesDeltaSchema, -} from '../../../openapi'; -import type { QueryOverride } from '../client-feature-toggle.controller'; - -export default class ClientFeatureToggleDeltaController extends Controller { - private readonly logger: Logger; - - private clientFeatureToggleService: ClientFeatureToggleService; - - private clientSpecService: ClientSpecService; - - private openApiService: OpenApiService; - - private flagResolver: IFlagResolver; - - constructor( - { - clientFeatureToggleService, - clientSpecService, - openApiService, - }: Pick< - IUnleashServices, - | 'clientFeatureToggleService' - | 'clientSpecService' - | 'openApiService' - | 'featureToggleService' - >, - config: IUnleashConfig, - ) { - super(config); - this.clientFeatureToggleService = clientFeatureToggleService; - this.clientSpecService = clientSpecService; - this.openApiService = openApiService; - this.flagResolver = config.flagResolver; - this.logger = config.getLogger('client-api/delta.js'); - - this.route({ - method: 'get', - path: '', - handler: this.getDelta, - permission: NONE, - middleware: [ - openApiService.validPath({ - summary: 'Get partial updates (SDK)', - description: - 'Initially returns the full set of feature flags available to the provided API key. When called again with the returned etag, only returns the flags that have changed', - operationId: 'getDelta', - tags: ['Unstable'], - responses: { - 200: createResponseSchema('clientFeaturesDeltaSchema'), - }, - }), - ], - }); - } - - async getDelta( - req: IAuthRequest, - res: Response, - ): Promise { - if (!this.flagResolver.isEnabled('deltaApi')) { - throw new NotFoundError(); - } - const query = await this.resolveQuery(req); - const etag = req.headers['if-none-match']; - - const sanitizedEtag = etag ? etag.replace(/^"(.*)"$/, '$1') : undefined; - - const currentSdkRevisionId = sanitizedEtag - ? Number.parseInt(sanitizedEtag) - : undefined; - - const changedFeatures = - await this.clientFeatureToggleService.getClientDelta( - currentSdkRevisionId, - query, - ); - - if (!changedFeatures) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - const lastEventId = - changedFeatures.events[changedFeatures.events.length - 1].eventId; - if (lastEventId === currentSdkRevisionId) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - - res.setHeader('ETag', `"${lastEventId}"`); - this.openApiService.respondWithValidation( - 200, - res, - clientFeaturesDeltaSchema.$id, - changedFeatures, - ); - } - - private async resolveQuery( - req: IAuthRequest, - ): Promise { - const { user, query } = req; - - const override: QueryOverride = {}; - if (user instanceof ApiUser) { - if (!isAllProjects(user.projects)) { - override.project = user.projects; - } - if (user.environment !== ALL) { - override.environment = user.environment; - } - } - - const inlineSegmentConstraints = - !this.clientSpecService.requestSupportsSpec(req, 'segments'); - - return this.prepQuery({ - ...query, - ...override, - inlineSegmentConstraints, - }); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - private paramToArray(param: any) { - if (!param) { - return param; - } - return Array.isArray(param) ? param : [param]; - } - - private async prepQuery({ - tag, - project, - namePrefix, - environment, - inlineSegmentConstraints, - }: IFeatureToggleQuery): Promise { - if ( - !tag && - !project && - !namePrefix && - !environment && - !inlineSegmentConstraints - ) { - return {}; - } - - const tagQuery = this.paramToArray(tag); - const projectQuery = this.paramToArray(project); - const query = await querySchema.validateAsync({ - tag: tagQuery, - project: projectQuery, - namePrefix, - environment, - inlineSegmentConstraints, - }); - - if (query.tag) { - query.tag = query.tag.map((q) => q.split(':')); - } - - return query; - } -} diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts deleted file mode 100644 index 493ca30068..0000000000 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - type DeltaEvent, - filterEventsByQuery, - filterHydrationEventByQuery, -} from './client-feature-toggle-delta'; -import { - DELTA_EVENT_TYPES, - type DeltaHydrationEvent, -} from './client-feature-toggle-delta-types'; - -const mockAdd = (params): any => { - const base = { - name: 'feature', - project: 'default', - stale: false, - type: 'release', - enabled: true, - strategies: [], - variants: [], - description: 'A feature', - impressionData: [], - dependencies: [], - }; - return { ...base, ...params }; -}; - -test('revision equal to the base case returns only later revisions ', () => { - const revisionList: DeltaEvent[] = [ - { - eventId: 2, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature4' }), - }, - { - eventId: 3, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature5' }), - }, - ]; - - const revisions = filterEventsByQuery(revisionList, 1, ['default'], ''); - - expect(revisions).toEqual([ - { - eventId: 2, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature4' }), - }, - { - eventId: 3, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature5' }), - }, - ]); -}); - -test('project filter removes features not in project and nameprefix', () => { - const revisionList: DeltaEvent[] = [ - { - eventId: 1, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature1', project: 'project1' }), - }, - { - eventId: 2, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'feature2', project: 'project2' }), - }, - { - eventId: 3, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'ffeature1', project: 'project1' }), - }, - ]; - - const revisions = filterEventsByQuery(revisionList, 0, ['project1'], 'ff'); - - expect(revisions).toEqual([ - { - eventId: 3, - type: DELTA_EVENT_TYPES.FEATURE_UPDATED, - feature: mockAdd({ name: 'ffeature1', project: 'project1' }), - }, - ]); -}); - -test('project filter removes features not in project in hydration', () => { - const revisionList: DeltaHydrationEvent = { - eventId: 1, - type: 'hydration', - segments: [ - { - name: 'test', - constraints: [], - id: 1, - }, - ], - features: [ - mockAdd({ name: 'feature1', project: 'project1' }), - mockAdd({ name: 'feature2', project: 'project2' }), - mockAdd({ name: 'myfeature2', project: 'project2' }), - ], - }; - - const revisions = filterHydrationEventByQuery( - revisionList, - ['project2'], - 'my', - ); - - expect(revisions).toEqual({ - eventId: 1, - type: 'hydration', - segments: [ - { - name: 'test', - constraints: [], - id: 1, - }, - ], - features: [mockAdd({ name: 'myfeature2', project: 'project2' })], - }); -}); diff --git a/src/lib/routes/client-api/index.ts b/src/lib/routes/client-api/index.ts index 6fa6f3197d..4e0ca30985 100644 --- a/src/lib/routes/client-api/index.ts +++ b/src/lib/routes/client-api/index.ts @@ -3,16 +3,11 @@ import FeatureController from '../../features/client-feature-toggles/client-feat import MetricsController from '../../features/metrics/instance/metrics'; import RegisterController from '../../features/metrics/instance/register'; import type { IUnleashConfig, IUnleashServices } from '../../types'; -import ClientFeatureToggleDeltaController from '../../features/client-feature-toggles/delta/client-feature-toggle-delta-controller'; export default class ClientApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { super(config); - this.use( - '/delta', - new ClientFeatureToggleDeltaController(services, config).router, - ); this.use('/features', new FeatureController(services, config).router); this.use('/metrics', new MetricsController(services, config).router); this.use('/register', new RegisterController(services, config).router);