1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01:00

chore: pull delta controller out of OSS (#9206)

We are moving delta controller to enterprise. This sets it up.
This commit is contained in:
Jaanus Sellin 2025-02-04 14:37:39 +02:00 committed by GitHub
parent c85c687816
commit 640db0c057
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 0 additions and 641 deletions

View File

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

View File

@ -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<ClientFeaturesDeltaSchema>,
): Promise<void> {
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<IFeatureToggleQuery> {
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<IFeatureToggleQuery> {
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;
}
}

View File

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

View File

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