mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +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:
parent
00c84f3c75
commit
7e3f0329ab
@ -76,6 +76,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@unleash/express-openapi": "^0.2.0",
|
||||||
"async": "^3.2.3",
|
"async": "^3.2.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
@ -96,8 +97,8 @@
|
|||||||
"helmet": "^5.0.0",
|
"helmet": "^5.0.0",
|
||||||
"joi": "^17.3.0",
|
"joi": "^17.3.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"knex": "^2.0.0",
|
|
||||||
"json-schema-to-ts": "^2.0.0",
|
"json-schema-to-ts": "^2.0.0",
|
||||||
|
"knex": "^2.0.0",
|
||||||
"log4js": "^6.0.0",
|
"log4js": "^6.0.0",
|
||||||
"make-fetch-happen": "^10.1.2",
|
"make-fetch-happen": "^10.1.2",
|
||||||
"memoizee": "^0.4.15",
|
"memoizee": "^0.4.15",
|
||||||
@ -113,13 +114,12 @@
|
|||||||
"pkginfo": "^0.4.1",
|
"pkginfo": "^0.4.1",
|
||||||
"prom-client": "^14.0.0",
|
"prom-client": "^14.0.0",
|
||||||
"response-time": "^2.3.2",
|
"response-time": "^2.3.2",
|
||||||
|
"semver": "^7.3.5",
|
||||||
"serve-favicon": "^2.5.0",
|
"serve-favicon": "^2.5.0",
|
||||||
"stoppable": "^1.1.0",
|
"stoppable": "^1.1.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^1.6.18",
|
||||||
"@unleash/express-openapi": "^0.2.0",
|
|
||||||
"unleash-frontend": "4.12.3",
|
"unleash-frontend": "4.12.3",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2"
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.18.2",
|
"@babel/core": "7.18.2",
|
||||||
@ -135,6 +135,7 @@
|
|||||||
"@types/node": "16.6.1",
|
"@types/node": "16.6.1",
|
||||||
"@types/nodemailer": "6.4.4",
|
"@types/nodemailer": "6.4.4",
|
||||||
"@types/owasp-password-strength-test": "1.3.0",
|
"@types/owasp-password-strength-test": "1.3.0",
|
||||||
|
"@types/semver": "^7.3.9",
|
||||||
"@types/stoppable": "1.1.1",
|
"@types/stoppable": "1.1.1",
|
||||||
"@types/supertest": "2.0.12",
|
"@types/supertest": "2.0.12",
|
||||||
"@types/type-is": "1.6.3",
|
"@types/type-is": "1.6.3",
|
||||||
|
@ -139,9 +139,12 @@ export default class FeatureToggleClientStore
|
|||||||
FeatureToggleClientStore.rowToStrategy(r),
|
FeatureToggleClientStore.rowToStrategy(r),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.inlineSegmentConstraints && r.segment_id) {
|
if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
|
||||||
this.addSegmentToStrategy(feature, r);
|
this.addSegmentToStrategy(feature, r);
|
||||||
} else if (!this.inlineSegmentConstraints && r.segment_id) {
|
} else if (
|
||||||
|
!featureQuery?.inlineSegmentConstraints &&
|
||||||
|
r.segment_id
|
||||||
|
) {
|
||||||
this.addSegmentIdsToStrategy(feature, r);
|
this.addSegmentIdsToStrategy(feature, r);
|
||||||
}
|
}
|
||||||
feature.impressionData = r.impression_data;
|
feature.impressionData = r.impression_data;
|
||||||
|
@ -6,6 +6,7 @@ import { createServices } from '../../services';
|
|||||||
import FeatureController from './feature';
|
import FeatureController from './feature';
|
||||||
import { createTestConfig } from '../../../test/config/test-config';
|
import { createTestConfig } from '../../../test/config/test-config';
|
||||||
import { secondsToMilliseconds } from 'date-fns';
|
import { secondsToMilliseconds } from 'date-fns';
|
||||||
|
import { ClientSpecService } from '../../services/client-spec-service';
|
||||||
|
|
||||||
async function getSetup() {
|
async function getSetup() {
|
||||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
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 base;
|
||||||
let request;
|
let request;
|
||||||
let destroy;
|
let destroy;
|
||||||
@ -61,18 +70,18 @@ test('should get empty getFeatures via client', () => {
|
|||||||
test('if caching is enabled should memoize', async () => {
|
test('if caching is enabled should memoize', async () => {
|
||||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||||
const getActive = jest.fn().mockReturnValue([]);
|
const getActive = jest.fn().mockReturnValue([]);
|
||||||
|
const clientSpecService = new ClientSpecService({ getLogger });
|
||||||
const featureToggleServiceV2 = {
|
const featureToggleServiceV2 = { getClientFeatures };
|
||||||
getClientFeatures,
|
const segmentService = { getActive };
|
||||||
};
|
|
||||||
|
|
||||||
const segmentService = {
|
|
||||||
getActive,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controller = new FeatureController(
|
const controller = new FeatureController(
|
||||||
// @ts-ignore
|
{
|
||||||
{ featureToggleServiceV2, segmentService },
|
clientSpecService,
|
||||||
|
// @ts-expect-error
|
||||||
|
featureToggleServiceV2,
|
||||||
|
// @ts-expect-error
|
||||||
|
segmentService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
getLogger,
|
getLogger,
|
||||||
experimental: {
|
experimental: {
|
||||||
@ -83,29 +92,27 @@ test('if caching is enabled should memoize', async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// @ts-ignore
|
|
||||||
await controller.getAll({ query: {} }, { json: () => {} });
|
await callGetAll(controller);
|
||||||
// @ts-ignore
|
await callGetAll(controller);
|
||||||
await controller.getAll({ query: {} }, { json: () => {} });
|
|
||||||
expect(getClientFeatures).toHaveBeenCalledTimes(1);
|
expect(getClientFeatures).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('if caching is not enabled all calls goes to service', async () => {
|
test('if caching is not enabled all calls goes to service', async () => {
|
||||||
const getClientFeatures = jest.fn().mockReturnValue([]);
|
const getClientFeatures = jest.fn().mockReturnValue([]);
|
||||||
|
|
||||||
const getActive = jest.fn().mockReturnValue([]);
|
const getActive = jest.fn().mockReturnValue([]);
|
||||||
|
const clientSpecService = new ClientSpecService({ getLogger });
|
||||||
const featureToggleServiceV2 = {
|
const featureToggleServiceV2 = { getClientFeatures };
|
||||||
getClientFeatures,
|
const segmentService = { getActive };
|
||||||
};
|
|
||||||
|
|
||||||
const segmentService = {
|
|
||||||
getActive,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controller = new FeatureController(
|
const controller = new FeatureController(
|
||||||
// @ts-ignore
|
{
|
||||||
{ featureToggleServiceV2, segmentService },
|
clientSpecService,
|
||||||
|
// @ts-expect-error
|
||||||
|
featureToggleServiceV2,
|
||||||
|
// @ts-expect-error
|
||||||
|
segmentService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
getLogger,
|
getLogger,
|
||||||
experimental: {
|
experimental: {
|
||||||
@ -116,10 +123,9 @@ test('if caching is not enabled all calls goes to service', async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// @ts-ignore
|
|
||||||
await controller.getAll({ query: {} }, { json: () => {} });
|
await callGetAll(controller);
|
||||||
// @ts-ignore
|
await callGetAll(controller);
|
||||||
await controller.getAll({ query: {} }, { json: () => {} });
|
|
||||||
expect(getClientFeatures).toHaveBeenCalledTimes(2);
|
expect(getClientFeatures).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import ApiUser from '../../types/api-user';
|
|||||||
import { ALL, isAllProjects } from '../../types/models/api-token';
|
import { ALL, isAllProjects } from '../../types/models/api-token';
|
||||||
import { SegmentService } from '../../services/segment-service';
|
import { SegmentService } from '../../services/segment-service';
|
||||||
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
|
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
|
||||||
|
import { ClientSpecService } from '../../services/client-spec-service';
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
@ -28,25 +29,29 @@ export default class FeatureController extends Controller {
|
|||||||
|
|
||||||
private segmentService: SegmentService;
|
private segmentService: SegmentService;
|
||||||
|
|
||||||
|
private clientSpecService: ClientSpecService;
|
||||||
|
|
||||||
private readonly cache: boolean;
|
private readonly cache: boolean;
|
||||||
|
|
||||||
private cachedFeatures: any;
|
private cachedFeatures: any;
|
||||||
|
|
||||||
private useGlobalSegments: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{
|
{
|
||||||
featureToggleServiceV2,
|
featureToggleServiceV2,
|
||||||
segmentService,
|
segmentService,
|
||||||
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'segmentService'>,
|
clientSpecService,
|
||||||
|
}: Pick<
|
||||||
|
IUnleashServices,
|
||||||
|
'featureToggleServiceV2' | 'segmentService' | 'clientSpecService'
|
||||||
|
>,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
) {
|
) {
|
||||||
super(config);
|
super(config);
|
||||||
const { experimental } = config;
|
const { experimental } = config;
|
||||||
this.featureToggleServiceV2 = featureToggleServiceV2;
|
this.featureToggleServiceV2 = featureToggleServiceV2;
|
||||||
this.segmentService = segmentService;
|
this.segmentService = segmentService;
|
||||||
|
this.clientSpecService = clientSpecService;
|
||||||
this.logger = config.getLogger('client-api/feature.js');
|
this.logger = config.getLogger('client-api/feature.js');
|
||||||
this.useGlobalSegments = !this.config.inlineSegmentConstraints;
|
|
||||||
|
|
||||||
this.get('/', this.getAll);
|
this.get('/', this.getAll);
|
||||||
this.get('/:featureName', this.getFeatureToggle);
|
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(
|
private async resolveFeaturesAndSegments(
|
||||||
query?: IFeatureToggleQuery,
|
query?: IFeatureToggleQuery,
|
||||||
): Promise<[FeatureConfigurationClient[], ISegment[]]> {
|
): Promise<[FeatureConfigurationClient[], ISegment[]]> {
|
||||||
let segments = this.resolveSegments();
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
this.featureToggleServiceV2.getClientFeatures(query),
|
this.featureToggleServiceV2.getClientFeatures(query),
|
||||||
segments,
|
this.segmentService.getActive(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,8 +98,14 @@ export default class FeatureController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = { ...query, ...override };
|
const inlineSegmentConstraints =
|
||||||
return this.prepQuery(q);
|
!this.clientSpecService.requestSupportsSpec(req, 'segments');
|
||||||
|
|
||||||
|
return this.prepQuery({
|
||||||
|
...query,
|
||||||
|
...override,
|
||||||
|
inlineSegmentConstraints,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||||
@ -118,10 +121,18 @@ export default class FeatureController extends Controller {
|
|||||||
project,
|
project,
|
||||||
namePrefix,
|
namePrefix,
|
||||||
environment,
|
environment,
|
||||||
|
inlineSegmentConstraints,
|
||||||
}: IFeatureToggleQuery): Promise<IFeatureToggleQuery> {
|
}: IFeatureToggleQuery): Promise<IFeatureToggleQuery> {
|
||||||
if (!tag && !project && !namePrefix && !environment) {
|
if (
|
||||||
|
!tag &&
|
||||||
|
!project &&
|
||||||
|
!namePrefix &&
|
||||||
|
!environment &&
|
||||||
|
!inlineSegmentConstraints
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagQuery = this.paramToArray(tag);
|
const tagQuery = this.paramToArray(tag);
|
||||||
const projectQuery = this.paramToArray(project);
|
const projectQuery = this.paramToArray(project);
|
||||||
const query = await querySchema.validateAsync({
|
const query = await querySchema.validateAsync({
|
||||||
@ -129,38 +140,30 @@ export default class FeatureController extends Controller {
|
|||||||
project: projectQuery,
|
project: projectQuery,
|
||||||
namePrefix,
|
namePrefix,
|
||||||
environment,
|
environment,
|
||||||
|
inlineSegmentConstraints,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (query.tag) {
|
if (query.tag) {
|
||||||
query.tag = query.tag.map((q) => q.split(':'));
|
query.tag = query.tag.map((q) => q.split(':'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(req: IAuthRequest, res: Response): Promise<void> {
|
async getAll(req: IAuthRequest, res: Response): Promise<void> {
|
||||||
const featureQuery = await this.resolveQuery(req);
|
const query = 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 response = {
|
const [features, segments] = this.cache
|
||||||
version,
|
? await this.cachedFeatures(query)
|
||||||
features,
|
: await Promise.all([
|
||||||
query: featureQuery,
|
this.featureToggleServiceV2.getClientFeatures(query),
|
||||||
};
|
this.segmentService.getActive(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (this.useGlobalSegments) {
|
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
|
||||||
res.json({
|
res.json({ version, features, query, segments });
|
||||||
...response,
|
|
||||||
segments,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
res.json(response);
|
res.json({ version, features, query });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,6 +116,7 @@ export const querySchema = joi
|
|||||||
project: joi.array().allow(null).items(nameType).optional(),
|
project: joi.array().allow(null).items(nameType).optional(),
|
||||||
namePrefix: joi.string().allow(null).optional(),
|
namePrefix: joi.string().allow(null).optional(),
|
||||||
environment: joi.string().allow(null).optional(),
|
environment: joi.string().allow(null).optional(),
|
||||||
|
inlineSegmentConstraints: joi.boolean().optional(),
|
||||||
})
|
})
|
||||||
.options({ allowUnknown: false, stripUnknown: true, abortEarly: false });
|
.options({ allowUnknown: false, stripUnknown: true, abortEarly: false });
|
||||||
|
|
||||||
|
29
src/lib/services/client-spec-service.test.ts
Normal file
29
src/lib/services/client-spec-service.test.ts
Normal 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);
|
||||||
|
});
|
54
src/lib/services/client-spec-service.ts
Normal file
54
src/lib/services/client-spec-service.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
@ -30,6 +30,7 @@ import ProjectHealthService from './project-health-service';
|
|||||||
import UserSplashService from './user-splash-service';
|
import UserSplashService from './user-splash-service';
|
||||||
import { SegmentService } from './segment-service';
|
import { SegmentService } from './segment-service';
|
||||||
import { OpenApiService } from './openapi-service';
|
import { OpenApiService } from './openapi-service';
|
||||||
|
import { ClientSpecService } from './client-spec-service';
|
||||||
|
|
||||||
export const createServices = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
@ -78,6 +79,7 @@ export const createServices = (
|
|||||||
const userSplashService = new UserSplashService(stores, config);
|
const userSplashService = new UserSplashService(stores, config);
|
||||||
const segmentService = new SegmentService(stores, config);
|
const segmentService = new SegmentService(stores, config);
|
||||||
const openApiService = new OpenApiService(config);
|
const openApiService = new OpenApiService(config);
|
||||||
|
const clientSpecService = new ClientSpecService(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
@ -109,6 +111,7 @@ export const createServices = (
|
|||||||
userSplashService,
|
userSplashService,
|
||||||
segmentService,
|
segmentService,
|
||||||
openApiService,
|
openApiService,
|
||||||
|
clientSpecService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -180,6 +180,7 @@ export interface IFeatureToggleQuery {
|
|||||||
project?: string[];
|
project?: string[];
|
||||||
namePrefix?: string;
|
namePrefix?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
|
inlineSegmentConstraints?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITag {
|
export interface ITag {
|
||||||
|
@ -26,6 +26,7 @@ import ClientMetricsServiceV2 from '../services/client-metrics/metrics-service-v
|
|||||||
import UserSplashService from '../services/user-splash-service';
|
import UserSplashService from '../services/user-splash-service';
|
||||||
import { SegmentService } from '../services/segment-service';
|
import { SegmentService } from '../services/segment-service';
|
||||||
import { OpenApiService } from '../services/openapi-service';
|
import { OpenApiService } from '../services/openapi-service';
|
||||||
|
import { ClientSpecService } from '../services/client-spec-service';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
accessService: AccessService;
|
accessService: AccessService;
|
||||||
@ -57,4 +58,5 @@ export interface IUnleashServices {
|
|||||||
userSplashService: UserSplashService;
|
userSplashService: UserSplashService;
|
||||||
segmentService: SegmentService;
|
segmentService: SegmentService;
|
||||||
openApiService: OpenApiService;
|
openApiService: OpenApiService;
|
||||||
|
clientSpecService: ClientSpecService;
|
||||||
}
|
}
|
||||||
|
23
src/lib/util/semver.test.ts
Normal file
23
src/lib/util/semver.test.ts
Normal 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
23
src/lib/util/semver.ts
Normal 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;
|
||||||
|
};
|
@ -1,5 +1,3 @@
|
|||||||
import semver from 'semver';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
constraintDateTypeSchema,
|
constraintDateTypeSchema,
|
||||||
constraintNumberTypeSchema,
|
constraintNumberTypeSchema,
|
||||||
@ -7,6 +5,7 @@ import {
|
|||||||
} from '../../schema/constraint-value-types';
|
} from '../../schema/constraint-value-types';
|
||||||
import BadDataError from '../../error/bad-data-error';
|
import BadDataError from '../../error/bad-data-error';
|
||||||
import { ILegalValue } from '../../types/stores/context-field-store';
|
import { ILegalValue } from '../../types/stores/context-field-store';
|
||||||
|
import { parseStrictSemVer } from '../semver';
|
||||||
|
|
||||||
export const validateNumber = async (value: unknown): Promise<void> => {
|
export const validateNumber = async (value: unknown): Promise<void> => {
|
||||||
await constraintNumberTypeSchema.validateAsync(value);
|
await constraintNumberTypeSchema.validateAsync(value);
|
||||||
@ -17,14 +16,15 @@ export const validateString = async (value: unknown): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const validateSemver = (value: unknown): 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 (!parseStrictSemVer(value)) {
|
||||||
|
throw new BadDataError(
|
||||||
if (result && cleanValue) return;
|
`the provided value is not a valid semver format. The value provided was: ${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> => {
|
export const validateDate = async (value: unknown): Promise<void> => {
|
||||||
|
@ -38,6 +38,7 @@ const fetchFeatures = (): Promise<IFeatureToggleClient[]> => {
|
|||||||
const fetchClientResponse = (): Promise<ApiResponse> => {
|
const fetchClientResponse = (): Promise<ApiResponse> => {
|
||||||
return app.request
|
return app.request
|
||||||
.get(FEATURES_CLIENT_BASE_PATH)
|
.get(FEATURES_CLIENT_BASE_PATH)
|
||||||
|
.set('Unleash-Client-Spec', '4.2.0')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then((res) => res.body);
|
.then((res) => res.body);
|
||||||
};
|
};
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
DEFAULT_SEGMENT_VALUES_LIMIT,
|
DEFAULT_SEGMENT_VALUES_LIMIT,
|
||||||
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
|
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
|
||||||
} from '../../../../lib/util/segments';
|
} from '../../../../lib/util/segments';
|
||||||
|
import { collectIds } from '../../../../lib/util/collect-ids';
|
||||||
|
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
@ -37,13 +38,6 @@ const fetchClientFeatures = (): Promise<IFeatureToggleClient[]> => {
|
|||||||
.then((res) => res.body.features);
|
.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 createSegment = (postData: object): Promise<unknown> => {
|
||||||
const user = { email: 'test@example.com' } as User;
|
const user = { email: 'test@example.com' } as User;
|
||||||
return app.services.segmentService.create(postData, user);
|
return app.services.segmentService.create(postData, user);
|
||||||
@ -102,7 +96,7 @@ afterEach(async () => {
|
|||||||
await db.stores.featureToggleStore.deleteAll();
|
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();
|
const constraints = mockConstraints();
|
||||||
await createSegment({ name: 'S1', constraints });
|
await createSegment({ name: 'S1', constraints });
|
||||||
await createSegment({ name: 'S2', 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();
|
const constraints = mockConstraints();
|
||||||
await createSegment({ name: 'S1', constraints });
|
await createSegment({ name: 'S1', constraints });
|
||||||
await createSegment({ name: 'S2', 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());
|
await createFeatureToggle(mockFeatureToggle());
|
||||||
const [feature1, feature2] = await fetchFeatures();
|
const [feature1, feature2] = await fetchFeatures();
|
||||||
const [segment1, segment2] = await fetchSegments();
|
const [segment1, segment2] = await fetchSegments();
|
||||||
|
const segmentIds = collectIds([segment1, segment2]);
|
||||||
|
|
||||||
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
|
await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
|
||||||
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
|
await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
|
||||||
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
|
await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
|
||||||
|
|
||||||
const globalSegments = await fetchGlobalSegments();
|
const unknownClientResponse = await app.request
|
||||||
expect(globalSegments).toBe(undefined);
|
.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);
|
||||||
});
|
});
|
||||||
|
@ -1121,6 +1121,11 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz"
|
resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz"
|
||||||
integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==
|
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@*":
|
"@types/serve-static@*":
|
||||||
version "1.13.10"
|
version "1.13.10"
|
||||||
resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz"
|
resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user