Skip to content

Commit

Permalink
feat: Add support for global segments (#1577)
Browse files Browse the repository at this point in the history
feat: Add support for global segments
  • Loading branch information
sighphyre committed May 30, 2022
1 parent f122e88 commit 0c1213f
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 27 deletions.
46 changes: 27 additions & 19 deletions src/lib/db/feature-toggle-client-store.ts
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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[]> {
Expand Down
17 changes: 15 additions & 2 deletions src/lib/routes/client-api/feature.test.ts
Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down
55 changes: 49 additions & 6 deletions src/lib/routes/client-api/feature.ts
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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> {
Expand Down Expand Up @@ -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> {
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/model.ts
Expand Up @@ -23,6 +23,7 @@ export interface IStrategyConfig {
id?: string;
name: string;
constraints?: IConstraint[];
segments?: number[];
parameters?: { [key: string]: string };
sortOrder?: number;
}
Expand Down
155 changes: 155 additions & 0 deletions src/test/e2e/api/client/global.segment.e2e.test.ts
@@ -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);
});

0 comments on commit 0c1213f

Please sign in to comment.