1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: client api with proper client segments and strategy variants (#4244)

This commit is contained in:
Mateusz Kwasniewski 2023-07-14 13:25:31 +02:00 committed by GitHub
parent 4cd4153412
commit e8ea79c967
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 136 additions and 38 deletions

View File

@ -6,6 +6,7 @@ import {
IFeatureToggleClient,
IFeatureToggleClientStore,
IFeatureToggleQuery,
IFlagResolver,
IStrategyConfig,
ITag,
PartialDeep,
@ -38,7 +39,14 @@ export default class FeatureToggleClientStore
private timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
private flagResolver: IFlagResolver;
constructor(
db: Db,
eventBus: EventEmitter,
getLogger: LogProvider,
flagResolver: IFlagResolver,
) {
this.db = db;
this.logger = getLogger('feature-toggle-client-store.ts');
this.timer = (action) =>
@ -46,6 +54,7 @@ export default class FeatureToggleClientStore
store: 'feature-toggle',
action,
});
this.flagResolver = flagResolver;
}
private async getAll({
@ -78,6 +87,7 @@ export default class FeatureToggleClientStore
'fs.parameters as parameters',
'fs.constraints as constraints',
'fs.sort_order as sort_order',
'fs.variants as strategy_variants',
'segments.id as segment_id',
'segments.constraints as segment_constraints',
] as (string | Raw<any>)[];
@ -170,9 +180,7 @@ export default class FeatureToggleClientStore
strategies: [],
};
if (this.isUnseenStrategyRow(feature, r) && !r.strategy_disabled) {
feature.strategies?.push(
FeatureToggleClientStore.rowToStrategy(r),
);
feature.strategies?.push(this.rowToStrategy(r));
}
if (this.isNewTag(feature, r)) {
this.addTag(feature, r);
@ -233,8 +241,8 @@ export default class FeatureToggleClientStore
return cleanedFeatures;
}
private static rowToStrategy(row: Record<string, any>): IStrategyConfig {
return {
private rowToStrategy(row: Record<string, any>): IStrategyConfig {
const strategy: IStrategyConfig = {
id: row.strategy_id,
name: row.strategy_name,
title: row.strategy_title,
@ -242,6 +250,10 @@ export default class FeatureToggleClientStore
parameters: mapValues(row.parameters || {}, ensureStringValue),
sortOrder: row.sort_order,
};
if (this.flagResolver.isEnabled('strategyVariant')) {
strategy.variants = row.strategy_variants || [];
}
return strategy;
}
private static rowToTag(row: Record<string, any>): ITag {

View File

@ -93,6 +93,7 @@ export const createStores = (
db,
eventBus,
getLogger,
config.flagResolver,
),
environmentStore: new EnvironmentStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger),

View File

@ -1,5 +1,10 @@
import { ISegmentStore } from '../types/stores/segment-store';
import { IConstraint, IFeatureStrategySegment, ISegment } from '../types/model';
import {
IClientSegment,
IConstraint,
IFeatureStrategySegment,
ISegment,
} from '../types/model';
import { Logger, LogProvider } from '../logger';
import EventEmitter from 'events';
import NotFoundError from '../error/notfound-error';
@ -150,6 +155,16 @@ export default class SegmentStore implements ISegmentStore {
return rows.map(this.mapRow);
}
async getActiveForClient(): Promise<IClientSegment[]> {
const fullSegments = await this.getActive();
return fullSegments.map((segments) => ({
id: segments.id,
name: segments.name,
constraints: segments.constraints,
}));
}
async getByStrategy(strategyId: string): Promise<ISegment[]> {
const rows = await this.db
.select(this.prefixColumns())

View File

@ -55,6 +55,7 @@ export const createFeatureToggleService = (
db,
eventBus,
getLogger,
flagResolver,
);
const projectStore = new ProjectStore(
db,

View File

@ -150,6 +150,7 @@ import {
telemetrySettingsSchema,
strategyVariantSchema,
createStrategyVariantSchema,
clientSegmentSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@ -357,6 +358,7 @@ export const schemas: UnleashSchemas = {
telemetrySettingsSchema,
strategyVariantSchema,
createStrategyVariantSchema,
clientSegmentSchema,
};
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.

View File

@ -151,7 +151,6 @@ test('clientFeaturesSchema unleash-proxy expected response', () => {
{
"id": 1,
"name": "some-name",
"description": null,
"constraints": [
{
"contextName": "some-name",

View File

@ -1,6 +1,6 @@
import { FromSchema } from 'json-schema-to-ts';
import { clientFeaturesQuerySchema } from './client-features-query-schema';
import { segmentSchema } from './segment-schema';
import { clientSegmentSchema } from './client-segment-schema';
import { constraintSchema } from './constraint-schema';
import { environmentSchema } from './environment-schema';
import { overrideSchema } from './override-schema';
@ -36,7 +36,7 @@ export const clientFeaturesSchema = {
'A list of [Segments](https://docs.getunleash.io/reference/segments) configured for this Unleash instance',
type: 'array',
items: {
$ref: '#/components/schemas/segmentSchema',
$ref: '#/components/schemas/clientSegmentSchema',
},
},
query: {
@ -50,7 +50,7 @@ export const clientFeaturesSchema = {
constraintSchema,
clientFeatureSchema,
environmentSchema,
segmentSchema,
clientSegmentSchema,
clientFeaturesQuerySchema,
overrideSchema,
parametersSchema,

View File

@ -0,0 +1,37 @@
import { FromSchema } from 'json-schema-to-ts';
import { constraintSchema } from './constraint-schema';
export const clientSegmentSchema = {
$id: '#/components/schemas/clientSegmentSchema',
type: 'object',
description:
'Represents a client API segment of users defined by a set of constraints.',
additionalProperties: false,
required: ['id', 'constraints'],
properties: {
id: {
type: 'number',
description: "The segment's id.",
},
name: {
type: 'string',
description: 'The name of the segment.',
example: 'segment A',
},
constraints: {
type: 'array',
description:
'List of constraints that determine which users are part of the segment',
items: {
$ref: '#/components/schemas/constraintSchema',
},
},
},
components: {
schemas: {
constraintSchema,
},
},
} as const;
export type ClientSegmentSchema = FromSchema<typeof clientSegmentSchema>;

View File

@ -149,3 +149,4 @@ export * from './advanced-playground-request-schema';
export * from './telemetry-settings-schema';
export * from './create-strategy-variant-schema';
export * from './strategy-variant-schema';
export * from './client-segment-schema';

View File

@ -1,5 +1,6 @@
import { FromSchema } from 'json-schema-to-ts';
import { constraintSchema } from './constraint-schema';
import { clientSegmentSchema } from './client-segment-schema';
export const segmentSchema = {
$id: '#/components/schemas/segmentSchema',
@ -9,28 +10,30 @@ export const segmentSchema = {
additionalProperties: false,
required: ['id', 'constraints'],
properties: {
id: {
type: 'number',
description: "The segment's id.",
},
name: {
type: 'string',
description: 'The name of the segment.',
example: 'segment A',
},
...clientSegmentSchema.properties,
description: {
type: 'string',
nullable: true,
description: 'The description of the segment.',
example: 'Segment A description',
},
constraints: {
type: 'array',
createdAt: {
type: 'string',
format: 'date-time',
description:
'List of constraints that determine which users are part of the segment',
items: {
$ref: '#/components/schemas/constraintSchema',
},
'The time the segment was created as a RFC 3339-conformant timestamp.',
example: '2023-07-05T12:56:00.000Z',
},
createdBy: {
type: 'string',
description: 'Which user created this segment',
example: 'johndoe',
},
project: {
type: 'string',
nullable: true,
description: 'The project the segment relates to, if applicable.',
example: 'default',
},
},
components: {

View File

@ -34,7 +34,10 @@ const callGetAll = async (controller: FeatureController) => {
await controller.getAll(
// @ts-expect-error
{ query: {}, header: () => undefined, headers: {} },
{ json: () => {}, setHeader: () => undefined },
{
json: () => {},
setHeader: () => undefined,
},
);
};
@ -76,12 +79,13 @@ 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 getActiveForClient = jest.fn().mockReturnValue([]);
const respondWithValidation = jest.fn().mockReturnValue({});
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const openApiService = { respondWithValidation, validPath };
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
const segmentService = { getActive, getActiveForClient };
const configurationRevisionService = { getMaxRevisionId: () => 1 };
const controller = new FeatureController(
@ -114,11 +118,12 @@ 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 getActiveForClient = jest.fn().mockReturnValue([]);
const respondWithValidation = jest.fn().mockReturnValue({});
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
const segmentService = { getActive, getActiveForClient };
const openApiService = { respondWithValidation, validPath };
const configurationRevisionService = { getMaxRevisionId: () => 1 };

View File

@ -3,11 +3,11 @@ import { Response } from 'express';
// eslint-disable-next-line import/no-extraneous-dependencies
import hashSum from 'hash-sum';
import Controller from '../controller';
import { IUnleashConfig, IUnleashServices } from '../../types';
import { IClientSegment, IUnleashConfig, IUnleashServices } from '../../types';
import FeatureToggleService from '../../services/feature-toggle-service';
import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema';
import { IFeatureToggleQuery, ISegment } from '../../types/model';
import { IFeatureToggleQuery } from '../../types/model';
import NotFoundError from '../../error/notfound-error';
import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user';
@ -58,7 +58,7 @@ export default class FeatureController extends Controller {
private featuresAndSegments: (
query: IFeatureToggleQuery,
etag: string,
) => Promise<[FeatureConfigurationClient[], ISegment[]]>;
) => Promise<[FeatureConfigurationClient[], IClientSegment[]]>;
constructor(
{
@ -145,10 +145,10 @@ export default class FeatureController extends Controller {
private async resolveFeaturesAndSegments(
query?: IFeatureToggleQuery,
): Promise<[FeatureConfigurationClient[], ISegment[]]> {
): Promise<[FeatureConfigurationClient[], IClientSegment[]]> {
return Promise.all([
this.featureToggleServiceV2.getClientFeatures(query),
this.segmentService.getActive(),
this.segmentService.getActiveForClient(),
]);
}

View File

@ -1,5 +1,5 @@
import { UpsertSegmentSchema } from 'lib/openapi';
import { IFeatureStrategy, ISegment, IUser } from 'lib/types';
import { IClientSegment, IFeatureStrategy, ISegment, IUser } from 'lib/types';
export interface ISegmentService {
updateStrategySegments: (
@ -19,6 +19,8 @@ export interface ISegmentService {
getActive(): Promise<ISegment[]>;
getActiveForClient(): Promise<IClientSegment[]>;
getAll(): Promise<ISegment[]>;
create(

View File

@ -1,6 +1,6 @@
import { IUnleashConfig } from '../types/option';
import { IEventStore } from '../types/stores/event-store';
import { IUnleashStores } from '../types';
import { IClientSegment, IUnleashStores } from '../types';
import { Logger } from '../logger';
import NameExistsError from '../error/name-exists-error';
import { ISegmentStore } from '../types/stores/segment-store';
@ -57,6 +57,10 @@ export class SegmentService implements ISegmentService {
return this.segmentStore.getActive();
}
async getActiveForClient(): Promise<IClientSegment[]> {
return this.segmentStore.getActiveForClient();
}
// Used by unleash-enterprise.
async getByStrategy(strategyId: string): Promise<ISegment[]> {
return this.segmentStore.getByStrategy(strategyId);

View File

@ -418,6 +418,12 @@ export interface IProjectWithCount extends IProject {
favorite?: boolean;
}
export interface IClientSegment {
id: number;
constraints: IConstraint[];
name: string;
}
export interface ISegment {
id: number;
name: string;

View File

@ -1,4 +1,4 @@
import { IFeatureStrategySegment, ISegment } from '../model';
import { IClientSegment, IFeatureStrategySegment, ISegment } from '../model';
import { Store } from './store';
import User from '../user';
@ -7,6 +7,8 @@ export interface ISegmentStore extends Store<ISegment, number> {
getActive(): Promise<ISegment[]>;
getActiveForClient(): Promise<IClientSegment[]>;
getByStrategy(strategyId: string): Promise<ISegment[]>;
create(

View File

@ -1,5 +1,9 @@
import { ISegmentStore } from '../../lib/types/stores/segment-store';
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
import {
IClientSegment,
IFeatureStrategySegment,
ISegment,
} from '../../lib/types/model';
export default class FakeSegmentStore implements ISegmentStore {
count(): Promise<number> {
@ -34,6 +38,10 @@ export default class FakeSegmentStore implements ISegmentStore {
return [];
}
async getActiveForClient(): Promise<IClientSegment[]> {
return [];
}
async getByStrategy(): Promise<ISegment[]> {
return [];
}