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:
parent
c85c687816
commit
640db0c057
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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' })],
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user