1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

refactor: avoid inlining segments for supported clients (#1640)

* refactor: add semver lib types

* refactor: avoid inlining segments for supported clients

* refactor: fix FeatureController tests

* refactor: use spec version instead of client version

* refactor: improve header validation errors
This commit is contained in:
olav 2022-06-02 14:07:46 +02:00 committed by GitHub
parent 00c84f3c75
commit 7e3f0329ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 260 additions and 92 deletions

View File

@ -76,6 +76,7 @@
]
},
"dependencies": {
"@unleash/express-openapi": "^0.2.0",
"async": "^3.2.3",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
@ -96,8 +97,8 @@
"helmet": "^5.0.0",
"joi": "^17.3.0",
"js-yaml": "^4.1.0",
"knex": "^2.0.0",
"json-schema-to-ts": "^2.0.0",
"knex": "^2.0.0",
"log4js": "^6.0.0",
"make-fetch-happen": "^10.1.2",
"memoizee": "^0.4.15",
@ -113,13 +114,12 @@
"pkginfo": "^0.4.1",
"prom-client": "^14.0.0",
"response-time": "^2.3.2",
"semver": "^7.3.5",
"serve-favicon": "^2.5.0",
"stoppable": "^1.1.0",
"type-is": "^1.6.18",
"@unleash/express-openapi": "^0.2.0",
"unleash-frontend": "4.12.3",
"uuid": "^8.3.2",
"semver": "^7.3.5"
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/core": "7.18.2",
@ -135,6 +135,7 @@
"@types/node": "16.6.1",
"@types/nodemailer": "6.4.4",
"@types/owasp-password-strength-test": "1.3.0",
"@types/semver": "^7.3.9",
"@types/stoppable": "1.1.1",
"@types/supertest": "2.0.12",
"@types/type-is": "1.6.3",

View File

@ -139,9 +139,12 @@ export default class FeatureToggleClientStore
FeatureToggleClientStore.rowToStrategy(r),
);
}
if (this.inlineSegmentConstraints && r.segment_id) {
if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
this.addSegmentToStrategy(feature, r);
} else if (!this.inlineSegmentConstraints && r.segment_id) {
} else if (
!featureQuery?.inlineSegmentConstraints &&
r.segment_id
) {
this.addSegmentIdsToStrategy(feature, r);
}
feature.impressionData = r.impression_data;

View File

@ -6,6 +6,7 @@ import { createServices } from '../../services';
import FeatureController from './feature';
import { createTestConfig } from '../../../test/config/test-config';
import { secondsToMilliseconds } from 'date-fns';
import { ClientSpecService } from '../../services/client-spec-service';
async function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
@ -30,6 +31,14 @@ async function getSetup() {
};
}
const callGetAll = async (controller: FeatureController) => {
await controller.getAll(
// @ts-expect-error
{ query: {}, header: () => undefined },
{ json: () => {} },
);
};
let base;
let request;
let destroy;
@ -61,18 +70,18 @@ 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 clientSpecService = new ClientSpecService({ getLogger });
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
const controller = new FeatureController(
// @ts-ignore
{ featureToggleServiceV2, segmentService },
{
clientSpecService,
// @ts-expect-error
featureToggleServiceV2,
// @ts-expect-error
segmentService,
},
{
getLogger,
experimental: {
@ -83,29 +92,27 @@ test('if caching is enabled should memoize', async () => {
},
},
);
// @ts-ignore
await controller.getAll({ query: {} }, { json: () => {} });
// @ts-ignore
await controller.getAll({ query: {} }, { json: () => {} });
await callGetAll(controller);
await callGetAll(controller);
expect(getClientFeatures).toHaveBeenCalledTimes(1);
});
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 clientSpecService = new ClientSpecService({ getLogger });
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
const controller = new FeatureController(
// @ts-ignore
{ featureToggleServiceV2, segmentService },
{
clientSpecService,
// @ts-expect-error
featureToggleServiceV2,
// @ts-expect-error
segmentService,
},
{
getLogger,
experimental: {
@ -116,10 +123,9 @@ test('if caching is not enabled all calls goes to service', async () => {
},
},
);
// @ts-ignore
await controller.getAll({ query: {} }, { json: () => {} });
// @ts-ignore
await controller.getAll({ query: {} }, { json: () => {} });
await callGetAll(controller);
await callGetAll(controller);
expect(getClientFeatures).toHaveBeenCalledTimes(2);
});

View File

@ -13,6 +13,7 @@ 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';
import { ClientSpecService } from '../../services/client-spec-service';
const version = 2;
@ -28,25 +29,29 @@ export default class FeatureController extends Controller {
private segmentService: SegmentService;
private clientSpecService: ClientSpecService;
private readonly cache: boolean;
private cachedFeatures: any;
private useGlobalSegments: boolean;
constructor(
{
featureToggleServiceV2,
segmentService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'segmentService'>,
clientSpecService,
}: Pick<
IUnleashServices,
'featureToggleServiceV2' | 'segmentService' | 'clientSpecService'
>,
config: IUnleashConfig,
) {
super(config);
const { experimental } = config;
this.featureToggleServiceV2 = featureToggleServiceV2;
this.segmentService = segmentService;
this.clientSpecService = clientSpecService;
this.logger = config.getLogger('client-api/feature.js');
this.useGlobalSegments = !this.config.inlineSegmentConstraints;
this.get('/', this.getAll);
this.get('/:featureName', this.getFeatureToggle);
@ -69,20 +74,12 @@ 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,
this.segmentService.getActive(),
]);
}
@ -101,8 +98,14 @@ export default class FeatureController extends Controller {
}
}
const q = { ...query, ...override };
return this.prepQuery(q);
const inlineSegmentConstraints =
!this.clientSpecService.requestSupportsSpec(req, 'segments');
return this.prepQuery({
...query,
...override,
inlineSegmentConstraints,
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -118,10 +121,18 @@ export default class FeatureController extends Controller {
project,
namePrefix,
environment,
inlineSegmentConstraints,
}: IFeatureToggleQuery): Promise<IFeatureToggleQuery> {
if (!tag && !project && !namePrefix && !environment) {
if (
!tag &&
!project &&
!namePrefix &&
!environment &&
!inlineSegmentConstraints
) {
return null;
}
const tagQuery = this.paramToArray(tag);
const projectQuery = this.paramToArray(project);
const query = await querySchema.validateAsync({
@ -129,38 +140,30 @@ export default class FeatureController extends Controller {
project: projectQuery,
namePrefix,
environment,
inlineSegmentConstraints,
});
if (query.tag) {
query.tag = query.tag.map((q) => q.split(':'));
}
return query;
}
async getAll(req: IAuthRequest, res: Response): Promise<void> {
const featureQuery = await this.resolveQuery(req);
let features, segments;
if (this.cache) {
[features, segments] = await this.cachedFeatures(featureQuery);
} else {
features = await this.featureToggleServiceV2.getClientFeatures(
featureQuery,
);
segments = await this.resolveSegments();
}
const query = await this.resolveQuery(req);
const response = {
version,
features,
query: featureQuery,
};
const [features, segments] = this.cache
? await this.cachedFeatures(query)
: await Promise.all([
this.featureToggleServiceV2.getClientFeatures(query),
this.segmentService.getActive(),
]);
if (this.useGlobalSegments) {
res.json({
...response,
segments,
});
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
res.json({ version, features, query, segments });
} else {
res.json(response);
res.json({ version, features, query });
}
}

View File

@ -116,6 +116,7 @@ export const querySchema = joi
project: joi.array().allow(null).items(nameType).optional(),
namePrefix: joi.string().allow(null).optional(),
environment: joi.string().allow(null).optional(),
inlineSegmentConstraints: joi.boolean().optional(),
})
.options({ allowUnknown: false, stripUnknown: true, abortEarly: false });

View File

@ -0,0 +1,29 @@
import { ClientSpecService } from './client-spec-service';
import getLogger from '../../test/fixtures/no-logger';
test('ClientSpecService validation', async () => {
const service = new ClientSpecService({ getLogger });
const fn = service.versionSupportsSpec.bind(service);
expect(fn('segments', undefined)).toEqual(false);
expect(fn('segments', '')).toEqual(false);
expect(() => fn('segments', 'a')).toThrow('Invalid prefix');
expect(() => fn('segments', '1.2')).toThrow('Invalid SemVer');
expect(() => fn('segments', 'v1.2.3')).toThrow('Invalid prefix');
expect(() => fn('segments', '=1.2.3')).toThrow('Invalid prefix');
expect(() => fn('segments', '1.2.3.4')).toThrow('Invalid SemVer');
});
test('ClientSpecService segments', async () => {
const service = new ClientSpecService({ getLogger });
const fn = service.versionSupportsSpec.bind(service);
expect(fn('segments', '0.0.0')).toEqual(false);
expect(fn('segments', '1.0.0')).toEqual(false);
expect(fn('segments', '4.1.9')).toEqual(false);
expect(fn('segments', '4.2.0')).toEqual(true);
expect(fn('segments', '4.2.1')).toEqual(true);
expect(fn('segments', '5.0.0')).toEqual(true);
});

View File

@ -0,0 +1,54 @@
import { IUnleashConfig } from '../types/option';
import { Logger } from '../logger';
import { Request } from 'express';
import semver, { SemVer } from 'semver';
import BadDataError from '../error/bad-data-error';
import { mustParseStrictSemVer, parseStrictSemVer } from '../util/semver';
export type ClientSpecFeature = 'segments';
export class ClientSpecService {
private readonly logger: Logger;
private readonly clientSpecHeader = 'Unleash-Client-Spec';
private readonly clientSpecFeatures: Record<ClientSpecFeature, SemVer> = {
segments: mustParseStrictSemVer('4.2.0'),
};
constructor(config: Pick<IUnleashConfig, 'getLogger'>) {
this.logger = config.getLogger('services/capability-service.ts');
}
requestSupportsSpec(request: Request, feature: ClientSpecFeature): boolean {
return this.versionSupportsSpec(
feature,
request.header(this.clientSpecHeader),
);
}
versionSupportsSpec(
feature: ClientSpecFeature,
version: string | undefined,
): boolean {
if (!version) {
return false;
}
const parsedVersion = parseStrictSemVer(version);
if (!parsedVersion && !/^\d/.test(version)) {
throw new BadDataError(
`Invalid prefix in the ${this.clientSpecHeader} header: "${version}".`,
);
}
if (!parsedVersion) {
throw new BadDataError(
`Invalid SemVer in the ${this.clientSpecHeader} header: "${version}".`,
);
}
return semver.gte(parsedVersion, this.clientSpecFeatures[feature]);
}
}

View File

@ -30,6 +30,7 @@ import ProjectHealthService from './project-health-service';
import UserSplashService from './user-splash-service';
import { SegmentService } from './segment-service';
import { OpenApiService } from './openapi-service';
import { ClientSpecService } from './client-spec-service';
export const createServices = (
stores: IUnleashStores,
@ -78,6 +79,7 @@ export const createServices = (
const userSplashService = new UserSplashService(stores, config);
const segmentService = new SegmentService(stores, config);
const openApiService = new OpenApiService(config);
const clientSpecService = new ClientSpecService(config);
return {
accessService,
@ -109,6 +111,7 @@ export const createServices = (
userSplashService,
segmentService,
openApiService,
clientSpecService,
};
};

View File

@ -180,6 +180,7 @@ export interface IFeatureToggleQuery {
project?: string[];
namePrefix?: string;
environment?: string;
inlineSegmentConstraints?: boolean;
}
export interface ITag {

View File

@ -26,6 +26,7 @@ import ClientMetricsServiceV2 from '../services/client-metrics/metrics-service-v
import UserSplashService from '../services/user-splash-service';
import { SegmentService } from '../services/segment-service';
import { OpenApiService } from '../services/openapi-service';
import { ClientSpecService } from '../services/client-spec-service';
export interface IUnleashServices {
accessService: AccessService;
@ -57,4 +58,5 @@ export interface IUnleashServices {
userSplashService: UserSplashService;
segmentService: SegmentService;
openApiService: OpenApiService;
clientSpecService: ClientSpecService;
}

View File

@ -0,0 +1,23 @@
import { mustParseStrictSemVer, parseStrictSemVer } from './semver';
test('parseStrictSemVer', () => {
expect(parseStrictSemVer('')).toEqual(null);
expect(parseStrictSemVer('v')).toEqual(null);
expect(parseStrictSemVer('v1')).toEqual(null);
expect(parseStrictSemVer('v1.2.3')).toEqual(null);
expect(parseStrictSemVer('=1.2.3')).toEqual(null);
expect(parseStrictSemVer('1.2')).toEqual(null);
expect(parseStrictSemVer('1.2.3.4')).toEqual(null);
expect(parseStrictSemVer('1.2.3')!.version).toEqual('1.2.3');
});
test('mustParseSemVer', () => {
expect(() => mustParseStrictSemVer('').version).toThrow();
expect(() => mustParseStrictSemVer('1').version).toThrow();
expect(() => mustParseStrictSemVer('1.2').version).toThrow();
expect(() => mustParseStrictSemVer('v1.2').version).toThrow();
expect(() => mustParseStrictSemVer('v1.2.3').version).toThrow();
expect(() => mustParseStrictSemVer('=1.2.3').version).toThrow();
expect(() => mustParseStrictSemVer('1.2.3.4').version).toThrow();
expect(mustParseStrictSemVer('1.2.3').version).toEqual('1.2.3');
});

23
src/lib/util/semver.ts Normal file
View File

@ -0,0 +1,23 @@
import semver, { SemVer } from 'semver';
export const parseStrictSemVer = (version: string): SemVer | null => {
if (semver.clean(version) !== version) {
return null;
}
try {
return semver.parse(version, { loose: false });
} catch {
return null;
}
};
export const mustParseStrictSemVer = (version: string): SemVer => {
const parsedVersion = parseStrictSemVer(version);
if (!parsedVersion) {
throw new Error('Could not parse SemVer string: ${version}');
}
return parsedVersion;
};

View File

@ -1,5 +1,3 @@
import semver from 'semver';
import {
constraintDateTypeSchema,
constraintNumberTypeSchema,
@ -7,6 +5,7 @@ import {
} from '../../schema/constraint-value-types';
import BadDataError from '../../error/bad-data-error';
import { ILegalValue } from '../../types/stores/context-field-store';
import { parseStrictSemVer } from '../semver';
export const validateNumber = async (value: unknown): Promise<void> => {
await constraintNumberTypeSchema.validateAsync(value);
@ -17,14 +16,15 @@ export const validateString = async (value: unknown): Promise<void> => {
};
export const validateSemver = (value: unknown): void => {
const cleanValue = semver.clean(value) === value;
if (typeof value !== 'string') {
throw new BadDataError(`the provided value is not a string.`);
}
const result = semver.valid(value);
if (result && cleanValue) return;
throw new BadDataError(
`the provided value is not a valid semver format. The value provided was: ${value}`,
);
if (!parseStrictSemVer(value)) {
throw new BadDataError(
`the provided value is not a valid semver format. The value provided was: ${value}`,
);
}
};
export const validateDate = async (value: unknown): Promise<void> => {

View File

@ -38,6 +38,7 @@ const fetchFeatures = (): Promise<IFeatureToggleClient[]> => {
const fetchClientResponse = (): Promise<ApiResponse> => {
return app.request
.get(FEATURES_CLIENT_BASE_PATH)
.set('Unleash-Client-Spec', '4.2.0')
.expect(200)
.then((res) => res.body);
};

View File

@ -12,6 +12,7 @@ import {
DEFAULT_SEGMENT_VALUES_LIMIT,
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
} from '../../../../lib/util/segments';
import { collectIds } from '../../../../lib/util/collect-ids';
let db: ITestDb;
let app: IUnleashTest;
@ -37,13 +38,6 @@ 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 createSegment = (postData: object): Promise<unknown> => {
const user = { email: 'test@example.com' } as User;
return app.services.segmentService.create(postData, user);
@ -102,7 +96,7 @@ afterEach(async () => {
await db.stores.featureToggleStore.deleteAll();
});
test('should add segments to features as constraints', async () => {
test('should inline segment constraints into features by default', async () => {
const constraints = mockConstraints();
await createSegment({ name: 'S1', constraints });
await createSegment({ name: 'S2', constraints });
@ -194,7 +188,7 @@ test('should validate feature strategy segment limit', async () => {
);
});
test('should not return segments in base of toggle response if inline is enabled', async () => {
test('should only return segments to clients with the segments capability', async () => {
const constraints = mockConstraints();
await createSegment({ name: 'S1', constraints });
await createSegment({ name: 'S2', constraints });
@ -204,11 +198,30 @@ test('should not return segments in base of toggle response if inline is enabled
await createFeatureToggle(mockFeatureToggle());
const [feature1, feature2] = await fetchFeatures();
const [segment1, segment2] = await fetchSegments();
const segmentIds = collectIds([segment1, segment2]);
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);
const unknownClientResponse = await app.request
.get(FEATURES_CLIENT_BASE_PATH)
.expect(200)
.then((res) => res.body);
const unknownClientConstraints = unknownClientResponse.features
.flatMap((f) => f.strategies)
.flatMap((s) => s.constraints);
expect(unknownClientResponse.segments).toEqual(undefined);
expect(unknownClientConstraints.length).toEqual(15);
const supportedClientResponse = await app.request
.get(FEATURES_CLIENT_BASE_PATH)
.set('Unleash-Client-Spec', '4.2.0')
.expect(200)
.then((res) => res.body);
const supportedClientConstraints = supportedClientResponse.features
.flatMap((f) => f.strategies)
.flatMap((s) => s.constraints);
expect(collectIds(supportedClientResponse.segments)).toEqual(segmentIds);
expect(supportedClientConstraints.length).toEqual(0);
});

View File

@ -1121,6 +1121,11 @@
resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz"
integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==
"@types/semver@^7.3.9":
version "7.3.9"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc"
integrity sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ==
"@types/serve-static@*":
version "1.13.10"
resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz"