mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: Add support for global segments (#1577)
feat: Add support for global segments
This commit is contained in:
parent
f122e884c7
commit
0c1213ff81
@ -78,16 +78,10 @@ export default class FeatureToggleClientStore
|
||||
'fs.strategy_name as strategy_name',
|
||||
'fs.parameters as parameters',
|
||||
'fs.constraints as constraints',
|
||||
'segments.id as segment_id',
|
||||
'segments.constraints as segment_constraints',
|
||||
];
|
||||
|
||||
if (inlineSegmentConstraints) {
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
'segments.id as segment_id',
|
||||
'segments.constraints as segment_constraints',
|
||||
];
|
||||
}
|
||||
|
||||
let query = this.db('features')
|
||||
.select(selectColumns)
|
||||
.fullOuterJoin(
|
||||
@ -105,17 +99,13 @@ export default class FeatureToggleClientStore
|
||||
.as('fe'),
|
||||
'fe.feature_name',
|
||||
'features.name',
|
||||
);
|
||||
|
||||
if (inlineSegmentConstraints) {
|
||||
query = query
|
||||
.fullOuterJoin(
|
||||
'feature_strategy_segment as fss',
|
||||
`fss.feature_strategy_id`,
|
||||
`fs.id`,
|
||||
)
|
||||
.fullOuterJoin('segments', `segments.id`, `fss.segment_id`);
|
||||
}
|
||||
)
|
||||
.fullOuterJoin(
|
||||
'feature_strategy_segment as fss',
|
||||
`fss.feature_strategy_id`,
|
||||
`fs.id`,
|
||||
)
|
||||
.fullOuterJoin('segments', `segments.id`, `fss.segment_id`);
|
||||
|
||||
query = query.where({
|
||||
archived,
|
||||
@ -155,6 +145,8 @@ export default class FeatureToggleClientStore
|
||||
}
|
||||
if (inlineSegmentConstraints && r.segment_id) {
|
||||
this.addSegmentToStrategy(feature, r);
|
||||
} else if (!inlineSegmentConstraints && r.segment_id) {
|
||||
this.addSegmentIdsToStrategy(feature, r);
|
||||
}
|
||||
feature.impressionData = r.impression_data;
|
||||
feature.enabled = !!r.enabled;
|
||||
@ -220,6 +212,22 @@ export default class FeatureToggleClientStore
|
||||
?.constraints.push(...row.segment_constraints);
|
||||
}
|
||||
|
||||
private addSegmentIdsToStrategy(
|
||||
feature: PartialDeep<IFeatureToggleClient>,
|
||||
row: Record<string, any>,
|
||||
) {
|
||||
const strategy = feature.strategies.find(
|
||||
(s) => s.id === row.strategy_id,
|
||||
);
|
||||
if (!strategy) {
|
||||
return;
|
||||
}
|
||||
if (!strategy.segments) {
|
||||
strategy.segments = [];
|
||||
}
|
||||
strategy.segments.push(row.segment_id);
|
||||
}
|
||||
|
||||
async getClient(
|
||||
featureQuery?: IFeatureToggleQuery,
|
||||
): Promise<IFeatureToggleClient[]> {
|
||||
|
@ -60,13 +60,19 @@ test('should get empty getFeatures via client', () => {
|
||||
|
||||
test('if caching is enabled should memoize', async () => {
|
||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||
const getActive = jest.fn().mockReturnValue([]);
|
||||
|
||||
const featureToggleServiceV2 = {
|
||||
getClientFeatures,
|
||||
};
|
||||
|
||||
const segmentService = {
|
||||
getActive,
|
||||
};
|
||||
|
||||
const controller = new FeatureController(
|
||||
// @ts-ignore
|
||||
{ featureToggleServiceV2 },
|
||||
{ featureToggleServiceV2, segmentService },
|
||||
{
|
||||
getLogger,
|
||||
experimental: {
|
||||
@ -87,12 +93,19 @@ test('if caching is enabled should memoize', async () => {
|
||||
test('if caching is not enabled all calls goes to service', async () => {
|
||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||
|
||||
const getActive = jest.fn().mockReturnValue([]);
|
||||
|
||||
const featureToggleServiceV2 = {
|
||||
getClientFeatures,
|
||||
};
|
||||
|
||||
const segmentService = {
|
||||
getActive,
|
||||
};
|
||||
|
||||
const controller = new FeatureController(
|
||||
// @ts-ignore
|
||||
{ featureToggleServiceV2 },
|
||||
{ featureToggleServiceV2, segmentService },
|
||||
{
|
||||
getLogger,
|
||||
experimental: {
|
||||
|
@ -6,11 +6,13 @@ import { IUnleashConfig } from '../../types/option';
|
||||
import FeatureToggleService from '../../services/feature-toggle-service';
|
||||
import { Logger } from '../../logger';
|
||||
import { querySchema } from '../../schema/feature-schema';
|
||||
import { IFeatureToggleQuery } from '../../types/model';
|
||||
import { IFeatureToggleQuery, ISegment } from '../../types/model';
|
||||
import NotFoundError from '../../error/notfound-error';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
import ApiUser from '../../types/api-user';
|
||||
import { ALL, isAllProjects } from '../../types/models/api-token';
|
||||
import { SegmentService } from '../../services/segment-service';
|
||||
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
|
||||
|
||||
const version = 2;
|
||||
|
||||
@ -24,27 +26,36 @@ export default class FeatureController extends Controller {
|
||||
|
||||
private featureToggleServiceV2: FeatureToggleService;
|
||||
|
||||
private segmentService: SegmentService;
|
||||
|
||||
private readonly cache: boolean;
|
||||
|
||||
private cachedFeatures: any;
|
||||
|
||||
private useGlobalSegments: boolean;
|
||||
|
||||
constructor(
|
||||
{
|
||||
featureToggleServiceV2,
|
||||
}: Pick<IUnleashServices, 'featureToggleServiceV2'>,
|
||||
segmentService,
|
||||
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'segmentService'>,
|
||||
config: IUnleashConfig,
|
||||
) {
|
||||
super(config);
|
||||
const { experimental } = config;
|
||||
this.featureToggleServiceV2 = featureToggleServiceV2;
|
||||
this.segmentService = segmentService;
|
||||
this.logger = config.getLogger('client-api/feature.js');
|
||||
this.get('/', this.getAll);
|
||||
this.get('/:featureName', this.getFeatureToggle);
|
||||
this.useGlobalSegments =
|
||||
experimental && !experimental?.segments?.inlineSegmentConstraints;
|
||||
|
||||
if (experimental && experimental.clientFeatureMemoize) {
|
||||
// @ts-ignore
|
||||
this.cache = experimental.clientFeatureMemoize.enabled;
|
||||
this.cachedFeatures = memoizee(
|
||||
(query) => this.featureToggleServiceV2.getClientFeatures(query),
|
||||
(query) => this.resolveFeaturesAndSegments(query),
|
||||
{
|
||||
promise: true,
|
||||
// @ts-ignore
|
||||
@ -58,6 +69,23 @@ export default class FeatureController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSegments() {
|
||||
if (this.useGlobalSegments) {
|
||||
return this.segmentService.getActive();
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
private async resolveFeaturesAndSegments(
|
||||
query?: IFeatureToggleQuery,
|
||||
): Promise<[FeatureConfigurationClient[], ISegment[]]> {
|
||||
let segments = this.resolveSegments();
|
||||
return Promise.all([
|
||||
this.featureToggleServiceV2.getClientFeatures(query),
|
||||
segments,
|
||||
]);
|
||||
}
|
||||
|
||||
private async resolveQuery(
|
||||
req: IAuthRequest,
|
||||
): Promise<IFeatureToggleQuery> {
|
||||
@ -110,15 +138,30 @@ export default class FeatureController extends Controller {
|
||||
|
||||
async getAll(req: IAuthRequest, res: Response): Promise<void> {
|
||||
const featureQuery = await this.resolveQuery(req);
|
||||
let features;
|
||||
let features, segments;
|
||||
if (this.cache) {
|
||||
features = await this.cachedFeatures(featureQuery);
|
||||
[features, segments] = await this.cachedFeatures(featureQuery);
|
||||
} else {
|
||||
features = await this.featureToggleServiceV2.getClientFeatures(
|
||||
featureQuery,
|
||||
);
|
||||
segments = await this.resolveSegments();
|
||||
}
|
||||
|
||||
const response = {
|
||||
version,
|
||||
features,
|
||||
query: featureQuery,
|
||||
};
|
||||
|
||||
if (this.useGlobalSegments) {
|
||||
res.json({
|
||||
...response,
|
||||
segments,
|
||||
});
|
||||
} else {
|
||||
res.json(response);
|
||||
}
|
||||
res.json({ version, features, query: featureQuery });
|
||||
}
|
||||
|
||||
async getFeatureToggle(req: IAuthRequest, res: Response): Promise<void> {
|
||||
|
@ -23,6 +23,7 @@ export interface IStrategyConfig {
|
||||
id?: string;
|
||||
name: string;
|
||||
constraints?: IConstraint[];
|
||||
segments?: number[];
|
||||
parameters?: { [key: string]: string };
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
155
src/test/e2e/api/client/global.segment.e2e.test.ts
Normal file
155
src/test/e2e/api/client/global.segment.e2e.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import dbInit, { ITestDb } from '../../helpers/database-init';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
import {
|
||||
IUnleashTest,
|
||||
setupAppWithCustomConfig,
|
||||
} from '../../helpers/test-helper';
|
||||
import {
|
||||
IConstraint,
|
||||
IFeatureToggleClient,
|
||||
ISegment,
|
||||
} from '../../../../lib/types/model';
|
||||
import { randomId } from '../../../../lib/util/random-id';
|
||||
import User from '../../../../lib/types/user';
|
||||
|
||||
let db: ITestDb;
|
||||
let app: IUnleashTest;
|
||||
|
||||
const FEATURES_ADMIN_BASE_PATH = '/api/admin/features';
|
||||
const FEATURES_CLIENT_BASE_PATH = '/api/client/features';
|
||||
|
||||
interface ApiResponse {
|
||||
features: IFeatureToggleClient[];
|
||||
version: number;
|
||||
segments: ISegment[];
|
||||
}
|
||||
|
||||
const fetchSegments = (): Promise<ISegment[]> => {
|
||||
return app.services.segmentService.getAll();
|
||||
};
|
||||
|
||||
const fetchFeatures = (): Promise<IFeatureToggleClient[]> => {
|
||||
return app.request
|
||||
.get(FEATURES_ADMIN_BASE_PATH)
|
||||
.expect(200)
|
||||
.then((res) => res.body.features);
|
||||
};
|
||||
|
||||
const fetchClientResponse = (): Promise<ApiResponse> => {
|
||||
return app.request
|
||||
.get(FEATURES_CLIENT_BASE_PATH)
|
||||
.expect(200)
|
||||
.then((res) => res.body);
|
||||
};
|
||||
|
||||
const createSegment = (postData: object): Promise<unknown> => {
|
||||
const user = { email: 'test@example.com' } as User;
|
||||
return app.services.segmentService.create(postData, user);
|
||||
};
|
||||
|
||||
const createFeatureToggle = (
|
||||
postData: object,
|
||||
expectStatusCode = 201,
|
||||
): Promise<unknown> => {
|
||||
return app.request
|
||||
.post(FEATURES_ADMIN_BASE_PATH)
|
||||
.send(postData)
|
||||
.expect(expectStatusCode);
|
||||
};
|
||||
|
||||
const addSegmentToStrategy = (
|
||||
segmentId: number,
|
||||
strategyId: string,
|
||||
): Promise<unknown> => {
|
||||
return app.services.segmentService.addToStrategy(segmentId, strategyId);
|
||||
};
|
||||
|
||||
const mockFeatureToggle = (): object => {
|
||||
return {
|
||||
name: randomId(),
|
||||
strategies: [{ name: randomId(), constraints: [], parameters: {} }],
|
||||
};
|
||||
};
|
||||
|
||||
const mockConstraints = (): IConstraint[] => {
|
||||
return Array.from({ length: 5 }).map(() => ({
|
||||
values: ['x', 'y', 'z'],
|
||||
operator: 'IN',
|
||||
contextName: 'a',
|
||||
}));
|
||||
};
|
||||
|
||||
const createTestSegments = async (): Promise<void> => {
|
||||
const constraints = mockConstraints();
|
||||
await createSegment({ name: 'S1', constraints });
|
||||
await createSegment({ name: 'S2', constraints });
|
||||
await createSegment({ name: 'S3', constraints });
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
const [feature1, feature2] = await fetchFeatures();
|
||||
const [segment1, segment2] = await fetchSegments();
|
||||
|
||||
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
|
||||
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
|
||||
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const experimentalConfig = {
|
||||
segments: {
|
||||
enableSegmentsAdminApi: true,
|
||||
enableSegmentsClientApi: true,
|
||||
inlineSegmentConstraints: false,
|
||||
},
|
||||
};
|
||||
|
||||
db = await dbInit('global_segments', getLogger, {
|
||||
experimental: experimentalConfig,
|
||||
});
|
||||
|
||||
app = await setupAppWithCustomConfig(db.stores, {
|
||||
experimental: experimentalConfig,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.stores.segmentStore.deleteAll();
|
||||
await db.stores.featureToggleStore.deleteAll();
|
||||
});
|
||||
|
||||
test('should return segments in base of toggle response if inline is disabled', async () => {
|
||||
await createTestSegments();
|
||||
|
||||
const clientFeatures = await fetchClientResponse();
|
||||
expect(clientFeatures.segments.length).toBeDefined();
|
||||
});
|
||||
|
||||
test('should only send segments that are in use', async () => {
|
||||
await createTestSegments();
|
||||
|
||||
const clientFeatures = await fetchClientResponse();
|
||||
//3 segments were created in createTestSegments, only 2 are in use
|
||||
expect(clientFeatures.segments.length).toEqual(2);
|
||||
});
|
||||
|
||||
test('should send all segments that are in use by feature', async () => {
|
||||
await createTestSegments();
|
||||
|
||||
const clientFeatures = await fetchClientResponse();
|
||||
const globalSegments = clientFeatures.segments;
|
||||
const globalSegmentIds = globalSegments.map((segment) => segment.id);
|
||||
const allSegmentIds = clientFeatures.features
|
||||
.map((feat) => feat.strategies.map((strategy) => strategy.segments))
|
||||
.flat()
|
||||
.flat()
|
||||
.filter((x) => !!x);
|
||||
const toggleSegmentIds = [...new Set(allSegmentIds)];
|
||||
|
||||
expect(globalSegmentIds).toEqual(toggleSegmentIds);
|
||||
});
|
@ -38,6 +38,13 @@ const fetchClientFeatures = (): Promise<IFeatureToggleClient[]> => {
|
||||
.then((res) => res.body.features);
|
||||
};
|
||||
|
||||
const fetchGlobalSegments = (): Promise<ISegment[] | undefined> => {
|
||||
return app.request
|
||||
.get(FEATURES_CLIENT_BASE_PATH)
|
||||
.expect(200)
|
||||
.then((res) => res.body.segments);
|
||||
};
|
||||
|
||||
const fetchClientSegmentsActive = (): Promise<ISegment[]> => {
|
||||
return app.request
|
||||
.get('/api/client/segments/active')
|
||||
@ -216,3 +223,22 @@ test('should validate feature strategy segment limit', async () => {
|
||||
addSegmentToStrategy(segments[5].id, feature1.strategies[0].id),
|
||||
).rejects.toThrow(`Strategies may not have more than ${limit} segments`);
|
||||
});
|
||||
|
||||
test('should not return segments in base of toggle response if inline is enabled', async () => {
|
||||
const constraints = mockConstraints();
|
||||
await createSegment({ name: 'S1', constraints });
|
||||
await createSegment({ name: 'S2', constraints });
|
||||
await createSegment({ name: 'S3', constraints });
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
await createFeatureToggle(mockFeatureToggle());
|
||||
const [feature1, feature2] = await fetchFeatures();
|
||||
const [segment1, segment2] = await fetchSegments();
|
||||
|
||||
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
|
||||
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
|
||||
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
|
||||
|
||||
const globalSegments = await fetchGlobalSegments();
|
||||
expect(globalSegments).toBe(undefined);
|
||||
});
|
||||
|
@ -10,6 +10,7 @@ import EnvironmentStore from '../../../lib/db/environment-store';
|
||||
import { IUnleashStores } from '../../../lib/types';
|
||||
import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store';
|
||||
import { DEFAULT_ENV } from '../../../lib/util/constants';
|
||||
import { IUnleashOptions } from 'lib/server-impl';
|
||||
|
||||
// require('db-migrate-shared').log.silence(false);
|
||||
|
||||
@ -79,6 +80,7 @@ export interface ITestDb {
|
||||
export default async function init(
|
||||
databaseSchema: string = 'test',
|
||||
getLogger: LogProvider = noLoggerProvider,
|
||||
configOverride: Partial<IUnleashOptions> = {},
|
||||
): Promise<ITestDb> {
|
||||
const config = createTestConfig({
|
||||
db: {
|
||||
@ -87,6 +89,7 @@ export default async function init(
|
||||
schema: databaseSchema,
|
||||
ssl: false,
|
||||
},
|
||||
...configOverride,
|
||||
getLogger,
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user