1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

Merge remote-tracking branch 'origin/main' into 4.14

This commit is contained in:
Gastón Fournier 2022-08-12 11:17:50 +02:00
commit 8d16997473
No known key found for this signature in database
GPG Key ID: BC3E6CD5E81633B8
109 changed files with 4506 additions and 1304 deletions

View File

@ -1,7 +1,7 @@
{
"name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "4.14.2",
"version": "4.15.0-beta.0",
"keywords": [
"unleash",
"feature toggle",
@ -98,6 +98,7 @@
"fast-json-patch": "^3.1.0",
"gravatar-url": "^3.1.0",
"helmet": "^5.0.0",
"ip": "^1.1.8",
"joi": "^17.3.0",
"js-yaml": "^4.1.0",
"json-schema-to-ts": "2.5.5",
@ -107,6 +108,7 @@
"memoizee": "^0.4.15",
"mime": "^3.0.0",
"multer": "^1.4.5-lts.1",
"murmurhash3js": "^3.0.1",
"mustache": "^4.1.0",
"nodemailer": "^6.5.0",
"openapi-types": "^12.0.0",
@ -122,13 +124,12 @@
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "^3.15.0",
"unleash-frontend": "4.14.1",
"unleash-frontend": "4.15.0-beta.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@babel/core": "7.18.9",
"@apidevtools/swagger-parser": "10.1.0",
"@babel/core": "7.18.10",
"@types/bcryptjs": "2.4.2",
"@types/express": "4.17.13",
"@types/express-session": "1.17.5",
@ -137,21 +138,21 @@
"@types/js-yaml": "4.0.5",
"@types/make-fetch-happen": "10.0.0",
"@types/memoizee": "0.4.8",
"@types/mime": "2.0.3",
"@types/mime": "3.0.1",
"@types/node": "16.6.1",
"@types/nodemailer": "6.4.4",
"@types/nodemailer": "6.4.5",
"@types/owasp-password-strength-test": "1.3.0",
"@types/semver": "7.3.10",
"@types/semver": "7.3.12",
"@types/stoppable": "1.1.1",
"@types/supertest": "2.0.12",
"@types/type-is": "1.6.3",
"@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.30.0",
"@typescript-eslint/parser": "5.30.0",
"@typescript-eslint/eslint-plugin": "5.33.0",
"@typescript-eslint/parser": "5.33.0",
"copyfiles": "2.4.1",
"coveralls": "3.1.1",
"del-cli": "5.0.0",
"eslint": "8.20.0",
"eslint": "8.21.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.0.0",
"eslint-config-prettier": "8.5.0",
@ -172,12 +173,13 @@
"ts-jest": "27.1.5",
"ts-node": "10.9.1",
"tsc-watch": "5.0.3",
"typescript": "4.7.4"
"typescript": "4.7.4",
"unleash-client": "3.15.0"
},
"resolutions": {
"async": "^3.2.3",
"db-migrate/rc/minimist": "^1.2.5",
"es5-ext": "0.10.61",
"es5-ext": "0.10.62",
"knex/liftoff/object.map/**/kind-of": "^6.0.3",
"knex/liftoff/findup-sync/micromatc/kind-of": "^6.0.3",
"knex/liftoff/findup-sync/micromatc/nanomatch/kind-of": "^6.0.3",

View File

@ -24,7 +24,7 @@ test('Trying to get events by name if db fails should yield empty list', async (
client: 'pg',
});
const store = new EventStore(db, getLogger);
const events = await store.getEventsFilterByType('application-created');
const events = await store.searchEvents({ type: 'application-created' });
expect(events).toBeTruthy();
expect(events.length).toBe(0);
});

View File

@ -1,9 +1,10 @@
import { EventEmitter } from 'events';
import { Knex } from 'knex';
import { DROP_FEATURES, IEvent, IBaseEvent } from '../types/events';
import { IEvent, IBaseEvent } from '../types/events';
import { LogProvider, Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store';
import { ITag } from '../types/model';
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
const EVENT_COLUMNS = [
'id',
@ -115,50 +116,44 @@ class EventStore extends EventEmitter implements IEventStore {
}
}
async getEventsFilterByType(name: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.limit(100)
.where('type', name)
.andWhere(
'id',
'>=',
this.db
.select(this.db.raw('coalesce(max(id),0) as id'))
.from(TABLE)
.where({ type: DROP_FEATURES }),
)
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
} catch (err) {
this.logger.error(err);
return [];
}
}
async searchEvents(search: SearchEventsSchema = {}): Promise<IEvent[]> {
let query = this.db
.select(EVENT_COLUMNS)
.from<IEventTable>(TABLE)
.limit(search.limit ?? 100)
.offset(search.offset ?? 0)
.orderBy('created_at', 'desc');
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where({ project })
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
} catch (err) {
return [];
if (search.type) {
query = query.andWhere({
type: search.type,
});
}
if (search.project) {
query = query.andWhere({
project: search.project,
});
}
if (search.feature) {
query = query.andWhere({
feature_name: search.feature,
});
}
if (search.query) {
query = query.where((where) =>
where
.orWhereRaw('type::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('created_by::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('data::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('pre_data::text ILIKE ?', `%${search.query}%`),
);
}
}
async getEventsForFeature(featureName: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where({ feature_name: featureName })
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
return (await query).map(this.rowToEvent);
} catch (err) {
return [];
}

View File

@ -225,12 +225,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_strategies.constraints as constraints',
'feature_strategies.sort_order as sort_order',
)
.fullOuterJoin(
.leftJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.fullOuterJoin('feature_strategies', function () {
.leftJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'=',
@ -241,7 +241,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_environments.environment',
);
})
.fullOuterJoin(
.leftJoin(
'environments',
'feature_environments.environment',
'environments.name',

View File

@ -57,6 +57,7 @@ export default class FeatureToggleClientStore
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
isAdmin: boolean = true,
includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]> {
const environment = featureQuery?.environment || DEFAULT_ENV;
const stopTimer = this.timer('getFeatureAdmin');
@ -84,7 +85,7 @@ export default class FeatureToggleClientStore
let query = this.db('features')
.select(selectColumns)
.modify(FeatureToggleStore.filterByArchived, archived)
.fullOuterJoin(
.leftJoin(
this.db('feature_strategies')
.select('*')
.where({ environment })
@ -92,7 +93,7 @@ export default class FeatureToggleClientStore
'fs.feature_name',
'features.name',
)
.fullOuterJoin(
.leftJoin(
this.db('feature_environments')
.select('feature_name', 'enabled', 'environment')
.where({ environment })
@ -166,7 +167,7 @@ export default class FeatureToggleClientStore
const features: IFeatureToggleClient[] = Object.values(featureToggles);
if (!isAdmin) {
if (!isAdmin && !includeStrategyIds) {
// We should not send strategy IDs from the client API,
// as this breaks old versions of the Go SDK (at least).
FeatureToggleClientStore.removeIdsFromStrategies(features);
@ -229,8 +230,9 @@ export default class FeatureToggleClientStore
async getClient(
featureQuery?: IFeatureToggleQuery,
includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]> {
return this.getAll(featureQuery, false, false);
return this.getAll(featureQuery, false, false, includeStrategyIds);
}
async getAdmin(

View File

@ -42,7 +42,6 @@ const rowToGroupUser = (row) => {
return {
userId: row.user_id,
groupId: row.group_id,
role: row.role,
joinedAt: row.created_at,
};
};
@ -112,7 +111,7 @@ export default class GroupStore implements IGroupStore {
async getAllUsersByGroups(groupIds: number[]): Promise<IGroupUser[]> {
const rows = await this.db
.select('gu.group_id', 'u.id as user_id', 'role', 'gu.created_at')
.select('gu.group_id', 'u.id as user_id', 'gu.created_at')
.from(`${T.GROUP_USER} AS gu`)
.join(`${T.USERS} AS u`, 'u.id', 'gu.user_id')
.whereIn('gu.group_id', groupIds);
@ -174,32 +173,12 @@ export default class GroupStore implements IGroupStore {
return {
group_id: groupId,
user_id: user.user.id,
role: user.role,
created_by: userName,
};
});
return (transaction || this.db).batchInsert(T.GROUP_USER, rows);
}
async updateExistingUsersInGroup(
groupId: number,
existingUsers: IGroupUserModel[],
transaction?: Transaction,
): Promise<void> {
const queries = [];
existingUsers.forEach((user) => {
queries.push(
(transaction || this.db)(T.GROUP_USER)
.where({ group_id: groupId, user_id: user.user.id })
.update({ role: user.role })
.transacting(transaction),
);
});
await Promise.all(queries);
}
async deleteOldUsersFromGroup(
deletableUsers: IGroupUser[],
transaction?: Transaction,
@ -221,7 +200,6 @@ export default class GroupStore implements IGroupStore {
): Promise<void> {
await this.db.transaction(async (tx) => {
await this.addNewUsersToGroup(groupId, newUsers, userName, tx);
await this.updateExistingUsersInGroup(groupId, existingUsers, tx);
await this.deleteOldUsersFromGroup(deletableUsers, tx);
});
}

View File

@ -25,7 +25,14 @@ const USER_COLUMNS = [
'created_at',
];
const USER_COLUMNS_PUBLIC = ['id', 'name', 'username', 'email', 'image_url'];
const USER_COLUMNS_PUBLIC = [
'id',
'name',
'username',
'email',
'image_url',
'seen_at',
];
const emptify = (value) => {
if (!value) {

View File

@ -1,5 +1,4 @@
import { OpenAPIV3 } from 'openapi-types';
import { addonParameterSchema } from './spec/addon-parameter-schema';
import { addonSchema } from './spec/addon-schema';
import { addonsSchema } from './spec/addons-schema';
@ -8,14 +7,12 @@ import { apiTokenSchema } from './spec/api-token-schema';
import { apiTokensSchema } from './spec/api-tokens-schema';
import { applicationSchema } from './spec/application-schema';
import { applicationsSchema } from './spec/applications-schema';
import { bootstrapUiSchema } from './spec/bootstrap-ui-schema';
import { changePasswordSchema } from './spec/change-password-schema';
import { clientApplicationSchema } from './spec/client-application-schema';
import { clientFeatureSchema } from './spec/client-feature-schema';
import { clientFeaturesQuerySchema } from './spec/client-features-query-schema';
import { clientFeaturesSchema } from './spec/client-features-schema';
import { clientMetricsSchema } from './spec/client-metrics-schema';
import { clientVariantSchema } from './spec/client-variant-schema';
import { cloneFeatureSchema } from './spec/clone-feature-schema';
import { constraintSchema } from './spec/constraint-schema';
import { contextFieldSchema } from './spec/context-field-schema';
@ -61,6 +58,9 @@ import { patchesSchema } from './spec/patches-schema';
import { patchSchema } from './spec/patch-schema';
import { permissionSchema } from './spec/permission-schema';
import { playgroundFeatureSchema } from './spec/playground-feature-schema';
import { playgroundStrategySchema } from './spec/playground-strategy-schema';
import { playgroundConstraintSchema } from './spec/playground-constraint-schema';
import { playgroundSegmentSchema } from './spec/playground-segment-schema';
import { playgroundRequestSchema } from './spec/playground-request-schema';
import { playgroundResponseSchema } from './spec/playground-response-schema';
import { projectEnvironmentSchema } from './spec/project-environment-schema';
@ -98,13 +98,13 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema';
import { IServerOption } from '../types';
import { URL } from 'url';
import { groupSchema } from './spec/group-schema';
import { groupsSchema } from './spec/groups-schema';
import { groupUserModelSchema } from './spec/group-user-model-schema';
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
import { searchEventsSchema } from './spec/search-events-schema';
// All schemas in `openapi/spec` should be listed here.
export const schemas = {
@ -116,14 +116,12 @@ export const schemas = {
apiTokensSchema,
applicationSchema,
applicationsSchema,
bootstrapUiSchema,
changePasswordSchema,
clientApplicationSchema,
clientFeatureSchema,
clientFeaturesQuerySchema,
clientFeaturesSchema,
clientMetricsSchema,
clientVariantSchema,
cloneFeatureSchema,
constraintSchema,
contextFieldSchema,
@ -170,6 +168,9 @@ export const schemas = {
patchSchema,
permissionSchema,
playgroundFeatureSchema,
playgroundStrategySchema,
playgroundConstraintSchema,
playgroundSegmentSchema,
playgroundRequestSchema,
playgroundResponseSchema,
projectEnvironmentSchema,
@ -178,6 +179,7 @@ export const schemas = {
resetPasswordSchema,
roleSchema,
sdkContextSchema,
searchEventsSchema,
segmentSchema,
setStrategySortOrderSchema,
sortOrderSchema,

View File

@ -17,7 +17,7 @@ Object {
}
`;
exports[`featureSchema overrides 1`] = `
exports[`featureSchema variant override values must be an array 1`] = `
Object {
"errors": Array [
Object {

View File

@ -1,157 +0,0 @@
import { validateSchema } from '../validate';
import { BootstrapUiSchema } from './bootstrap-ui-schema';
test('bootstrapUiSchema', () => {
const data: BootstrapUiSchema = {
uiConfig: {
flags: { E: true },
authenticationType: 'open-source',
unleashUrl: 'http://localhost:4242',
version: '4.14.0-beta.0',
baseUriPath: '',
versionInfo: {
current: { oss: '4.14.0-beta.0', enterprise: '' },
latest: {},
isLatest: true,
instanceId: '51c9190a-4ff5-4f47-b73a-7aebe06f9331',
},
},
user: {
isAPI: false,
id: 1,
username: 'admin',
imageUrl:
'https://gravatar.com/avatar/21232f297a57a5a743894a0e4a801fc3?size=42&default=retro',
seenAt: '2022-06-27T12:19:15.838Z',
loginAttempts: 0,
createdAt: '2022-04-08T10:59:25.072Z',
permissions: [
{ permission: 'READ_API_TOKEN' },
{
project: 'default',
environment: 'staging',
permission: 'CREATE_FEATURE_STRATEGY',
},
{
project: 'default',
environment: 'staging',
permission: 'UPDATE_FEATURE_STRATEGY',
},
{ project: 'default', permission: 'UPDATE_FEATURE' },
],
},
email: false,
context: [
{
name: 'appName',
description: 'Allows you to constrain on application name',
stickiness: false,
sortOrder: 2,
legalValues: [],
createdAt: '2022-04-08T10:59:24.374Z',
},
{
name: 'currentTime',
description: '',
stickiness: false,
sortOrder: 10,
legalValues: [],
createdAt: '2022-05-18T08:15:18.917Z',
},
{
name: 'environment',
description:
'Allows you to constrain on application environment',
stickiness: false,
sortOrder: 0,
legalValues: [],
createdAt: '2022-04-08T10:59:24.374Z',
},
{
name: 'userId',
description: 'Allows you to constrain on userId',
stickiness: false,
sortOrder: 1,
legalValues: [],
createdAt: '2022-04-08T10:59:24.374Z',
},
],
featureTypes: [
{
id: 'release',
name: 'Release',
description:
'Release feature toggles are used to release new features.',
lifetimeDays: 40,
},
{
id: 'experiment',
name: 'Experiment',
description:
'Experiment feature toggles are used to test and verify multiple different versions of a feature.',
lifetimeDays: 40,
},
{
id: 'operational',
name: 'Operational',
description:
'Operational feature toggles are used to control aspects of a rollout.',
lifetimeDays: 7,
},
],
tagTypes: [
{
name: 'simple',
description: 'Used to simplify filtering of features',
icon: '#',
},
{ name: 'hashtag', description: '', icon: null },
],
strategies: [
{
displayName: 'Standard',
name: 'default',
editable: false,
description:
'The standard strategy is strictly on / off for your entire userbase.',
parameters: [],
deprecated: false,
},
{
displayName: null,
name: 'gradualRolloutRandom',
editable: true,
description:
'Randomly activate the feature toggle. No stickiness.',
parameters: [
{
name: 'percentage',
type: 'percentage',
description: '',
required: false,
},
],
deprecated: true,
},
],
projects: [
{
name: 'Default',
id: 'default',
description: 'Default project',
health: 74,
featureCount: 10,
memberCount: 3,
updatedAt: '2022-06-28T17:33:53.963Z',
},
],
};
expect(
validateSchema('#/components/schemas/bootstrapUiSchema', {}),
).not.toBeUndefined();
expect(
validateSchema('#/components/schemas/bootstrapUiSchema', data),
).toBeUndefined();
});

View File

@ -1,94 +0,0 @@
import { FromSchema } from 'json-schema-to-ts';
import { uiConfigSchema } from './ui-config-schema';
import { userSchema } from './user-schema';
import { permissionSchema } from './permission-schema';
import { featureTypeSchema } from './feature-type-schema';
import { tagTypeSchema } from './tag-type-schema';
import { contextFieldSchema } from './context-field-schema';
import { strategySchema } from './strategy-schema';
import { projectSchema } from './project-schema';
import { versionSchema } from './version-schema';
import { legalValueSchema } from './legal-value-schema';
export const bootstrapUiSchema = {
$id: '#/components/schemas/bootstrapUiSchema',
type: 'object',
additionalProperties: false,
required: [
'uiConfig',
'user',
'email',
'context',
'featureTypes',
'tagTypes',
'strategies',
'projects',
],
properties: {
uiConfig: {
$ref: '#/components/schemas/uiConfigSchema',
},
user: {
type: 'object',
required: [...userSchema.required],
properties: {
...userSchema.properties,
permissions: {
type: 'array',
items: {
$ref: '#/components/schemas/permissionSchema',
},
},
},
},
email: {
type: 'boolean',
},
context: {
type: 'array',
items: {
$ref: '#/components/schemas/contextFieldSchema',
},
},
featureTypes: {
type: 'array',
items: {
$ref: '#/components/schemas/featureTypeSchema',
},
},
tagTypes: {
type: 'array',
items: {
$ref: '#/components/schemas/tagTypeSchema',
},
},
strategies: {
type: 'array',
items: {
$ref: '#/components/schemas/strategySchema',
},
},
projects: {
type: 'array',
items: {
$ref: '#/components/schemas/projectSchema',
},
},
},
components: {
schemas: {
uiConfigSchema,
userSchema,
permissionSchema,
contextFieldSchema,
featureTypeSchema,
tagTypeSchema,
strategySchema,
projectSchema,
versionSchema,
legalValueSchema,
},
},
} as const;
export type BootstrapUiSchema = FromSchema<typeof bootstrapUiSchema>;

View File

@ -2,7 +2,8 @@ import { FromSchema } from 'json-schema-to-ts';
import { constraintSchema } from './constraint-schema';
import { parametersSchema } from './parameters-schema';
import { featureStrategySchema } from './feature-strategy-schema';
import { clientVariantSchema } from './client-variant-schema';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
export const clientFeatureSchema = {
$id: '#/components/schemas/clientFeatureSchema',
@ -52,7 +53,7 @@ export const clientFeatureSchema = {
variants: {
type: 'array',
items: {
$ref: '#/components/schemas/clientVariantSchema',
$ref: '#/components/schemas/variantSchema',
},
nullable: true,
},
@ -62,7 +63,8 @@ export const clientFeatureSchema = {
constraintSchema,
parametersSchema,
featureStrategySchema,
clientVariantSchema,
variantSchema,
overrideSchema,
},
},
} as const;

View File

@ -22,6 +22,16 @@ test('clientFeaturesSchema required fields', () => {
weight: 1,
weightType: 'b',
stickiness: 'c',
payload: {
type: 'a',
value: 'b',
},
overrides: [
{
contextName: 'a',
values: ['b'],
},
],
},
],
},

View File

@ -7,7 +7,7 @@ import { overrideSchema } from './override-schema';
import { parametersSchema } from './parameters-schema';
import { featureStrategySchema } from './feature-strategy-schema';
import { clientFeatureSchema } from './client-feature-schema';
import { clientVariantSchema } from './client-variant-schema';
import { variantSchema } from './variant-schema';
export const clientFeaturesSchema = {
$id: '#/components/schemas/clientFeaturesSchema',
@ -43,7 +43,7 @@ export const clientFeaturesSchema = {
overrideSchema,
parametersSchema,
featureStrategySchema,
clientVariantSchema,
variantSchema,
},
},
} as const;

View File

@ -1,37 +0,0 @@
import { FromSchema } from 'json-schema-to-ts';
export const clientVariantSchema = {
$id: '#/components/schemas/clientVariantSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'weight'],
properties: {
name: {
type: 'string',
},
weight: {
type: 'number',
},
weightType: {
type: 'string',
},
stickiness: {
type: 'string',
},
payload: {
type: 'object',
required: ['type', 'value'],
properties: {
type: {
type: 'string',
},
value: {
type: 'string',
},
},
},
},
components: {},
} as const;
export type ClientVariantSchema = FromSchema<typeof clientVariantSchema>;

View File

@ -1,36 +1,57 @@
import { FromSchema } from 'json-schema-to-ts';
import { ALL_OPERATORS } from '../../util/constants';
export const constraintSchema = {
$id: '#/components/schemas/constraintSchema',
export const constraintSchemaBase = {
type: 'object',
additionalProperties: false,
required: ['contextName', 'operator'],
description:
'A strategy constraint. For more information, refer to [the strategy constraint reference documentation](https://docs.getunleash.io/advanced/strategy_constraints)',
properties: {
contextName: {
description:
'The name of the context field that this constraint should apply to.',
example: 'appName',
type: 'string',
},
operator: {
description:
'The operator to use when evaluating this constraint. For more information about the various operators, refer to [the strategy constraint operator documentation](https://docs.getunleash.io/advanced/strategy_constraints#strategy-constraint-operators).',
type: 'string',
enum: ALL_OPERATORS,
},
caseInsensitive: {
description:
'Whether the operator should be case sensitive or not. Defaults to `false` (being case sensitive).',
type: 'boolean',
default: false,
},
inverted: {
description:
'Whether the result should be negated or not. If `true`, will turn a `true` result into a `false` result and vice versa.',
type: 'boolean',
default: false,
},
values: {
type: 'array',
description:
'The context values that should be used for constraint evaluation. Use this property instead of `value` for properties that accept multiple values.',
items: {
type: 'string',
},
},
value: {
description:
'The context value that should be used for constraint evaluation. Use this property instead of `values` for properties that only accept single values.',
type: 'string',
},
},
components: {},
} as const;
export const constraintSchema = {
$id: '#/components/schemas/constraintSchema',
additionalProperties: false,
...constraintSchemaBase,
} as const;
export type ConstraintSchema = FromSchema<typeof constraintSchema>;

View File

@ -8,7 +8,11 @@ test('contextFieldSchema', () => {
stickiness: false,
sortOrder: 0,
createdAt: '2022-01-01T00:00:00.000Z',
legalValues: [],
legalValues: [
{ value: 'a' },
{ value: 'b', description: '' },
{ value: 'c', description: 'd' },
],
};
expect(

View File

@ -6,7 +6,7 @@ export const featureEventsSchema = {
$id: '#/components/schemas/featureEventsSchema',
type: 'object',
additionalProperties: false,
required: ['toggleName', 'events'],
required: ['events'],
properties: {
version: { type: 'number' },
toggleName: {

View File

@ -52,7 +52,23 @@ test('featureSchema constraints', () => {
).toMatchSnapshot();
});
test('featureSchema overrides', () => {
test('featureSchema variants should only have a few required fields', () => {
const data = {
name: 'a',
variants: [
{
name: 'a',
weight: 1,
},
],
};
expect(
validateSchema('#/components/schemas/featureSchema', data),
).toBeUndefined();
});
test('featureSchema variant override values must be an array', () => {
const data = {
name: 'a',
variants: [

View File

@ -5,15 +5,12 @@ export const groupUserModelSchema = {
$id: '#/components/schemas/groupUserModelSchema',
type: 'object',
additionalProperties: false,
required: ['role', 'user'],
required: ['user'],
properties: {
joinedAt: {
type: 'string',
format: 'date-time',
},
role: {
type: 'string',
},
user: {
$ref: '#/components/schemas/userSchema',
},

View File

@ -9,7 +9,6 @@ test('groupsSchema', () => {
name: 'Group',
users: [
{
role: 'Owner',
user: {
id: 3,
},

View File

@ -0,0 +1,20 @@
import { constraintSchemaBase } from './constraint-schema';
import { FromSchema } from 'json-schema-to-ts';
export const playgroundConstraintSchema = {
$id: '#/components/schemas/playgroundConstraintSchema',
additionalProperties: false,
...constraintSchemaBase,
required: [...constraintSchemaBase.required, 'result'],
properties: {
...constraintSchemaBase.properties,
result: {
description: 'Whether this was evaluated as true or false.',
type: 'boolean',
},
},
} as const;
export type PlaygroundConstraintSchema = FromSchema<
typeof playgroundConstraintSchema
>;

View File

@ -1,23 +1,150 @@
import fc, { Arbitrary } from 'fast-check';
import { urlFriendlyString, variants } from '../../../test/arbitraries.test';
import {
strategyConstraint,
urlFriendlyString,
variants,
} from '../../../test/arbitraries.test';
import { validateSchema } from '../validate';
import { PlaygroundConstraintSchema } from './playground-constraint-schema';
import {
playgroundFeatureSchema,
PlaygroundFeatureSchema,
} from './playground-feature-schema';
import { PlaygroundSegmentSchema } from './playground-segment-schema';
import {
playgroundStrategyEvaluation,
PlaygroundStrategySchema,
} from './playground-strategy-schema';
const playgroundStrategyConstraint =
(): Arbitrary<PlaygroundConstraintSchema> =>
fc
.tuple(fc.boolean(), strategyConstraint())
.map(([result, constraint]) => ({
...constraint,
result,
}));
const playgroundStrategyConstraints = (): Arbitrary<
PlaygroundConstraintSchema[]
> => fc.array(playgroundStrategyConstraint());
const playgroundSegment = (): Arbitrary<PlaygroundSegmentSchema> =>
fc.record({
name: fc.string({ minLength: 1 }),
id: fc.nat(),
result: fc.boolean(),
constraints: playgroundStrategyConstraints(),
});
const playgroundStrategy = (
name: string,
parameters: Arbitrary<Record<string, string>>,
): Arbitrary<PlaygroundStrategySchema> =>
fc.record({
id: fc.uuid(),
name: fc.constant(name),
result: fc.oneof(
fc.record({
evaluationStatus: fc.constant(
playgroundStrategyEvaluation.evaluationComplete,
),
enabled: fc.boolean(),
}),
fc.record({
evaluationStatus: fc.constant(
playgroundStrategyEvaluation.evaluationIncomplete,
),
enabled: fc.constantFrom(
playgroundStrategyEvaluation.unknownResult,
false as false,
),
}),
),
parameters,
constraints: playgroundStrategyConstraints(),
segments: fc.array(playgroundSegment()),
});
const playgroundStrategies = (): Arbitrary<PlaygroundStrategySchema[]> =>
fc.array(
fc.oneof(
playgroundStrategy('default', fc.constant({})),
playgroundStrategy(
'flexibleRollout',
fc.record({
groupId: fc.lorem({ maxCount: 1 }),
rollout: fc.nat({ max: 100 }).map(String),
stickiness: fc.constantFrom(
'default',
'userId',
'sessionId',
),
}),
),
playgroundStrategy(
'applicationHostname',
fc.record({
hostNames: fc
.uniqueArray(fc.domain())
.map((domains) => domains.join(',')),
}),
),
playgroundStrategy(
'userWithId',
fc.record({
userIds: fc
.uniqueArray(fc.emailAddress())
.map((ids) => ids.join(',')),
}),
),
playgroundStrategy(
'remoteAddress',
fc.record({
IPs: fc.uniqueArray(fc.ipV4()).map((ips) => ips.join(',')),
}),
),
),
);
export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
fc
.tuple(
fc.boolean(),
variants(),
fc.nat(),
fc.record({
isEnabledInCurrentEnvironment: fc.boolean(),
projectId: urlFriendlyString(),
name: urlFriendlyString(),
strategies: playgroundStrategies(),
}),
)
.map(([isEnabled, generatedVariants, activeVariantIndex, feature]) => {
.map(([generatedVariants, activeVariantIndex, feature]) => {
const strategyResult = () => {
const { strategies } = feature;
if (
strategies.some(
(strategy) => strategy.result.enabled === true,
)
) {
return true;
}
if (
strategies.some(
(strategy) => strategy.result.enabled === 'unknown',
)
) {
return 'unknown';
}
return false;
};
const isEnabled =
feature.isEnabledInCurrentEnvironment &&
strategyResult() === true;
// the active variant is the disabled variant if the feature is
// disabled or has no variants.
let activeVariant = { name: 'disabled', enabled: false } as {
@ -42,7 +169,7 @@ export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
: undefined;
activeVariant = {
enabled: isEnabled,
enabled: true,
name: targetVariant.name,
payload: targetPayload,
};
@ -51,6 +178,10 @@ export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
return {
...feature,
isEnabled,
strategies: {
result: strategyResult(),
data: feature.strategies,
},
variants: generatedVariants,
variant: activeVariant,
};

View File

@ -1,6 +1,15 @@
import { FromSchema } from 'json-schema-to-ts';
import { parametersSchema } from './parameters-schema';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
import {
playgroundStrategyEvaluation,
playgroundStrategySchema,
} from './playground-strategy-schema';
import { playgroundConstraintSchema } from './playground-constraint-schema';
import { playgroundSegmentSchema } from './playground-segment-schema';
export const unknownFeatureEvaluationResult = 'unevaluated' as const;
export const playgroundFeatureSchema = {
$id: '#/components/schemas/playgroundFeatureSchema',
@ -8,28 +17,102 @@ export const playgroundFeatureSchema = {
'A simplified feature toggle model intended for the Unleash playground.',
type: 'object',
additionalProperties: false,
required: ['name', 'projectId', 'isEnabled', 'variant', 'variants'],
required: [
'name',
'projectId',
'isEnabled',
'isEnabledInCurrentEnvironment',
'variant',
'variants',
'strategies',
],
properties: {
name: { type: 'string', example: 'my-feature' },
projectId: { type: 'string', example: 'my-project' },
isEnabled: { type: 'boolean', example: true },
name: {
type: 'string',
example: 'my-feature',
description: "The feature's name.",
},
projectId: {
type: 'string',
example: 'my-project',
description: 'The ID of the project that contains this feature.',
},
strategies: {
type: 'object',
additionalProperties: false,
required: ['result', 'data'],
properties: {
result: {
description: `The cumulative results of all the feature's strategies. Can be \`true\`,
\`false\`, or \`${playgroundStrategyEvaluation.unknownResult}\`.
This property will only be \`${playgroundStrategyEvaluation.unknownResult}\`
if one or more of the strategies can't be fully evaluated and the rest of the strategies
all resolve to \`false\`.`,
anyOf: [
{ type: 'boolean' },
{
type: 'string',
enum: [playgroundStrategyEvaluation.unknownResult],
},
],
},
data: {
description: 'The strategies that apply to this feature.',
type: 'array',
items: {
$ref: playgroundStrategySchema.$id,
},
},
},
},
isEnabledInCurrentEnvironment: {
type: 'boolean',
description:
'Whether the feature is active and would be evaluated in the provided environment in a normal SDK context.',
},
isEnabled: {
description: `Whether this feature is enabled or not in the current environment.
If a feature can't be fully evaluated (that is, \`strategies.result\` is \`${playgroundStrategyEvaluation.unknownResult}\`),
this will be \`false\` to align with how client SDKs treat unresolved feature states.`,
type: 'boolean',
example: true,
},
variant: {
description: `The feature variant you receive based on the provided context or the _disabled
variant_. If a feature is disabled or doesn't have any
variants, you would get the _disabled variant_.
Otherwise, you'll get one of thefeature's defined variants.`,
type: 'object',
additionalProperties: false,
required: ['name', 'enabled'],
properties: {
name: { type: 'string' },
enabled: { type: 'boolean' },
name: {
type: 'string',
description:
"The variant's name. If there is no variant or if the toggle is disabled, this will be `disabled`",
example: 'red-variant',
},
enabled: {
type: 'boolean',
description:
"Whether the variant is enabled or not. If the feature is disabled or if it doesn't have variants, this property will be `false`",
},
payload: {
type: 'object',
additionalProperties: false,
required: ['type', 'value'],
description: 'An optional payload attached to the variant.',
properties: {
type: {
description: 'The format of the payload.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: { type: 'string' },
value: {
type: 'string',
description: 'The payload value stringified.',
example: '{"property": "value"}',
},
},
},
},
@ -38,7 +121,17 @@ export const playgroundFeatureSchema = {
},
variants: { type: 'array', items: { $ref: variantSchema.$id } },
},
components: { schemas: { variantSchema, overrideSchema } },
components: {
schemas: {
playgroundStrategySchema,
playgroundConstraintSchema,
playgroundSegmentSchema,
parametersSchema,
variantSchema,
overrideSchema,
},
variants: { type: 'array', items: { $ref: variantSchema.$id } },
},
} as const;
export type PlaygroundFeatureSchema = FromSchema<

View File

@ -8,7 +8,11 @@ export const playgroundRequestSchema = {
type: 'object',
required: ['environment', 'context'],
properties: {
environment: { type: 'string', example: 'development' },
environment: {
type: 'string',
example: 'development',
description: 'The environment to evaluate toggles in.',
},
projects: {
oneOf: [
{
@ -25,6 +29,7 @@ export const playgroundRequestSchema = {
],
},
context: {
description: 'The context to use when evaluating toggles',
$ref: sdkContextSchema.$id,
},
},

View File

@ -2,8 +2,13 @@ import { FromSchema } from 'json-schema-to-ts';
import { sdkContextSchema } from './sdk-context-schema';
import { playgroundRequestSchema } from './playground-request-schema';
import { playgroundFeatureSchema } from './playground-feature-schema';
import { constraintSchema } from './constraint-schema';
import { parametersSchema } from './parameters-schema';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
import { playgroundConstraintSchema } from './playground-constraint-schema';
import { playgroundSegmentSchema } from './playground-segment-schema';
import { playgroundStrategySchema } from './playground-strategy-schema';
export const playgroundResponseSchema = {
$id: '#/components/schemas/playgroundResponseSchema',
@ -13,17 +18,26 @@ export const playgroundResponseSchema = {
required: ['features', 'input'],
properties: {
input: {
description: 'The given input used to evaluate the features.',
$ref: playgroundRequestSchema.$id,
},
features: {
type: 'array',
items: { $ref: playgroundFeatureSchema.$id },
description: 'The list of features that have been evaluated.',
items: {
$ref: playgroundFeatureSchema.$id,
},
},
},
components: {
schemas: {
constraintSchema,
parametersSchema,
playgroundConstraintSchema,
playgroundFeatureSchema,
playgroundRequestSchema,
playgroundSegmentSchema,
playgroundStrategySchema,
sdkContextSchema,
variantSchema,
overrideSchema,

View File

@ -0,0 +1,38 @@
import { FromSchema } from 'json-schema-to-ts';
import { playgroundConstraintSchema } from './playground-constraint-schema';
export const playgroundSegmentSchema = {
$id: '#/components/schemas/playgroundSegmentSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'id', 'constraints', 'result'],
properties: {
id: {
description: "The segment's id.",
type: 'integer',
},
name: {
description: 'The name of the segment.',
example: 'segment A',
type: 'string',
},
result: {
description: 'Whether this was evaluated as true or false.',
type: 'boolean',
},
constraints: {
type: 'array',
description: 'The list of constraints in this segment.',
items: { $ref: playgroundConstraintSchema.$id },
},
},
components: {
schemas: {
playgroundConstraintSchema,
},
},
} as const;
export type PlaygroundSegmentSchema = FromSchema<
typeof playgroundSegmentSchema
>;

View File

@ -0,0 +1,113 @@
import { FromSchema } from 'json-schema-to-ts';
import { parametersSchema } from './parameters-schema';
import { playgroundConstraintSchema } from './playground-constraint-schema';
import { playgroundSegmentSchema } from './playground-segment-schema';
export const playgroundStrategyEvaluation = {
evaluationComplete: 'complete',
evaluationIncomplete: 'incomplete',
unknownResult: 'unknown',
} as const;
export const strategyEvaluationResults = {
anyOf: [
{
type: 'object',
additionalProperties: false,
required: ['evaluationStatus', 'enabled'],
properties: {
evaluationStatus: {
type: 'string',
description:
"Signals that this strategy could not be evaluated. This is most likely because you're using a custom strategy that Unleash doesn't know about.",
enum: [playgroundStrategyEvaluation.evaluationIncomplete],
},
enabled: {
description:
"Whether this strategy resolves to `false` or if it might resolve to `true`. Because Unleash can't evaluate the strategy, it can't say for certain whether it will be `true`, but if you have failing constraints or segments, it _can_ determine that your strategy would be `false`.",
anyOf: [
{ type: 'boolean', enum: [false] },
{
type: 'string',
enum: [playgroundStrategyEvaluation.unknownResult],
},
],
},
},
},
{
type: 'object',
additionalProperties: false,
required: ['evaluationStatus', 'enabled'],
properties: {
evaluationStatus: {
description:
'Signals that this strategy was evaluated successfully.',
type: 'string',
enum: ['complete'],
},
enabled: {
type: 'boolean',
description:
'Whether this strategy evaluates to true or not.',
},
},
},
],
} as const;
export const playgroundStrategySchema = {
$id: '#/components/schemas/playgroundStrategySchema',
type: 'object',
additionalProperties: false,
required: ['id', 'name', 'result', 'segments', 'constraints', 'parameters'],
properties: {
name: {
description: "The strategy's name.",
type: 'string',
},
id: {
description: "The strategy's id.",
type: 'string',
},
result: {
description: `The strategy's evaluation result. If the strategy is a custom strategy that Unleash can't evaluate, \`evaluationStatus\` will be \`${playgroundStrategyEvaluation.unknownResult}\`. Otherwise, it will be \`true\` or \`false\``,
...strategyEvaluationResults,
},
segments: {
type: 'array',
description:
"The strategy's segments and their evaluation results.",
items: {
$ref: playgroundSegmentSchema.$id,
},
},
constraints: {
type: 'array',
description:
"The strategy's constraints and their evaluation results.",
items: {
$ref: playgroundConstraintSchema.$id,
},
},
parameters: {
description:
"The strategy's constraints and their evaluation results.",
example: {
myParam1: 'param value',
},
$ref: parametersSchema.$id,
},
},
components: {
schemas: {
playgroundConstraintSchema,
playgroundSegmentSchema,
parametersSchema,
},
},
} as const;
export type PlaygroundStrategySchema = FromSchema<
typeof playgroundStrategySchema
>;

View File

@ -0,0 +1,22 @@
import { validateSchema } from '../validate';
import { ProjectSchema } from './project-schema';
test('projectSchema', () => {
const data: ProjectSchema = {
name: 'Default',
id: 'default',
description: 'Default project',
health: 74,
featureCount: 10,
memberCount: 3,
updatedAt: '2022-06-28T17:33:53.963Z',
};
expect(
validateSchema('#/components/schemas/projectSchema', {}),
).not.toBeUndefined();
expect(
validateSchema('#/components/schemas/projectSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,47 @@
import { FromSchema } from 'json-schema-to-ts';
export const searchEventsSchema = {
$id: '#/components/schemas/searchEventsSchema',
type: 'object',
description: `
Search for events by type, project, feature, free-text query,
or a combination thereof. Pass an empty object to fetch all events.
`,
properties: {
type: {
type: 'string',
description: 'Find events by event type (case-sensitive).',
},
project: {
type: 'string',
description: 'Find events by project ID (case-sensitive).',
},
feature: {
type: 'string',
description: 'Find events by feature toggle name (case-sensitive).',
},
query: {
type: 'string',
description: `
Find events by a free-text search query.
The query will be matched against the event type,
the username or email that created the event (if any),
and the event data payload (if any).
`,
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 100,
},
offset: {
type: 'integer',
minimum: 0,
default: 0,
},
},
components: {},
} as const;
export type SearchEventsSchema = FromSchema<typeof searchEventsSchema>;

View File

@ -0,0 +1,28 @@
import { validateSchema } from '../validate';
import { TagTypesSchema } from './tag-types-schema';
test('tagTypesSchema', () => {
const data: TagTypesSchema = {
version: 1,
tagTypes: [
{
name: 'simple',
description: 'Used to simplify filtering of features',
icon: '#',
},
{
name: 'hashtag',
description: '',
icon: null,
},
],
};
expect(
validateSchema('#/components/schemas/tagTypesSchema', {}),
).not.toBeUndefined();
expect(
validateSchema('#/components/schemas/tagTypesSchema', data),
).toBeUndefined();
});

View File

@ -28,6 +28,9 @@ export const uiConfigSchema = {
disablePasswordAuth: {
type: 'boolean',
},
emailEnabled: {
type: 'boolean',
},
segmentValuesLimit: {
type: 'number',
},

View File

@ -0,0 +1,22 @@
import { validateSchema } from '../validate';
import { UserSchema } from './user-schema';
test('userSchema', () => {
const data: UserSchema = {
isAPI: false,
id: 1,
username: 'admin',
imageUrl: 'avatar',
seenAt: '2022-06-27T12:19:15.838Z',
loginAttempts: 0,
createdAt: '2022-04-08T10:59:25.072Z',
};
expect(
validateSchema('#/components/schemas/userSchema', {}),
).not.toBeUndefined();
expect(
validateSchema('#/components/schemas/userSchema', data),
).toBeUndefined();
});

View File

@ -5,7 +5,7 @@ export const variantSchema = {
$id: '#/components/schemas/variantSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'weight', 'weightType', 'stickiness'],
required: ['name', 'weight'],
properties: {
name: {
type: 'string',

View File

@ -11,9 +11,20 @@ const badRequestResponse = {
description: 'The request data does not match what we expect.',
} as const;
const notFoundResponse = {
description: 'The requested resource was not found.',
} as const;
const conflictResponse = {
description:
'The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.',
} as const;
const standardResponses = {
400: badRequestResponse,
401: unauthorizedResponse,
404: notFoundResponse,
409: conflictResponse,
} as const;
type StandardResponses = typeof standardResponses;
@ -22,9 +33,9 @@ export const getStandardResponses = (
...statusCodes: (keyof StandardResponses)[]
): Partial<StandardResponses> =>
statusCodes.reduce(
(acc, n) => ({
(acc, statusCode) => ({
...acc,
[n]: standardResponses[n],
[statusCode]: standardResponses[statusCode],
}),
{} as Partial<StandardResponses>,
);

View File

@ -1,63 +0,0 @@
import supertest from 'supertest';
import { createTestConfig } from '../../../test/config/test-config';
import { randomId } from '../../util/random-id';
import createStores from '../../../test/fixtures/store';
import getApp from '../../app';
import { createServices } from '../../services';
const uiConfig = {
headerBackground: 'red',
slogan: 'hello',
};
async function getSetup() {
const base = `/random${randomId()}`;
const config = createTestConfig({
server: { baseUriPath: base },
ui: uiConfig,
});
const stores = createStores();
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
return {
base,
request: supertest(app),
destroy: () => {
services.versionService.destroy();
services.clientInstanceService.destroy();
services.apiTokenService.destroy();
},
};
}
let request;
let base;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
request = setup.request;
base = setup.base;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
test('should get ui config', async () => {
const { body } = await request
.get(`${base}/api/admin/ui-bootstrap`)
.expect('Content-Type', /json/)
.expect(200);
expect(body.uiConfig.slogan).toEqual('hello');
expect(body.email).toEqual(false);
expect(body.user).toHaveProperty('permissions');
expect(body.context).toBeInstanceOf(Array);
expect(body.tagTypes).toBeInstanceOf(Array);
expect(body.strategies).toBeInstanceOf(Array);
expect(body.projects).toBeInstanceOf(Array);
});

View File

@ -1,173 +0,0 @@
import { Response } from 'express';
import Controller from '../controller';
import { AuthedRequest } from '../../types/core';
import { Logger } from '../../logger';
import ContextService from '../../services/context-service';
import TagTypeService from '../../services/tag-type-service';
import StrategyService from '../../services/strategy-service';
import ProjectService from '../../services/project-service';
import { AccessService } from '../../services/access-service';
import { EmailService } from '../../services/email-service';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
import VersionService from '../../services/version-service';
import FeatureTypeService from '../../services/feature-type-service';
import version from '../../util/version';
import { IContextField } from '../../types/stores/context-field-store';
import { IFeatureType } from '../../types/stores/feature-type-store';
import { ITagType } from '../../types/stores/tag-type-store';
import { IStrategy } from '../../types/stores/strategy-store';
import { IProject } from '../../types/model';
import { IUserPermission } from '../../types/stores/access-store';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import {
BootstrapUiSchema,
bootstrapUiSchema,
} from '../../openapi/spec/bootstrap-ui-schema';
import { serializeDates } from '../../types/serialize-dates';
/**
* Provides admin UI configuration.
* Not to be confused with SDK bootstrapping.
*/
class BootstrapUIController extends Controller {
private logger: Logger;
private accessService: AccessService;
private contextService: ContextService;
private emailService: EmailService;
private featureTypeService: FeatureTypeService;
private projectService: ProjectService;
private strategyService: StrategyService;
private tagTypeService: TagTypeService;
private versionService: VersionService;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
contextService,
tagTypeService,
strategyService,
projectService,
accessService,
emailService,
versionService,
featureTypeService,
openApiService,
}: Pick<
IUnleashServices,
| 'contextService'
| 'tagTypeService'
| 'strategyService'
| 'projectService'
| 'accessService'
| 'emailService'
| 'versionService'
| 'featureTypeService'
| 'openApiService'
>,
) {
super(config);
this.contextService = contextService;
this.tagTypeService = tagTypeService;
this.strategyService = strategyService;
this.projectService = projectService;
this.accessService = accessService;
this.featureTypeService = featureTypeService;
this.emailService = emailService;
this.versionService = versionService;
this.openApiService = openApiService;
this.logger = config.getLogger('routes/admin-api/bootstrap-ui.ts');
this.route({
method: 'get',
path: '',
handler: this.bootstrap,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['other'],
operationId: 'getBootstrapUiData',
responses: {
202: createResponseSchema('bootstrapUiSchema'),
},
}),
],
});
}
async bootstrap(
req: AuthedRequest,
res: Response<BootstrapUiSchema>,
): Promise<void> {
const jobs: [
Promise<IContextField[]>,
Promise<IFeatureType[]>,
Promise<ITagType[]>,
Promise<IStrategy[]>,
Promise<IProject[]>,
Promise<IUserPermission[]>,
] = [
this.contextService.getAll(),
this.featureTypeService.getAll(),
this.tagTypeService.getAll(),
this.strategyService.getStrategies(),
this.projectService.getProjects(),
this.accessService.getPermissionsForUser(req.user),
];
const [
context,
featureTypes,
tagTypes,
strategies,
projects,
userPermissions,
] = await Promise.all(jobs);
const authenticationType =
this.config.authentication && this.config.authentication.type;
const versionInfo = this.versionService.getVersionInfo();
const uiConfig = {
...this.config.ui,
authenticationType,
unleashUrl: this.config.server.unleashUrl,
version,
baseUriPath: this.config.server.baseUriPath,
versionInfo,
};
this.openApiService.respondWithValidation(
200,
res,
bootstrapUiSchema.$id,
{
uiConfig,
user: {
...serializeDates(req.user),
permissions: userPermissions,
},
email: this.emailService.isEnabled(),
context: serializeDates(context),
featureTypes,
tagTypes,
strategies,
projects: serializeDates(projects),
},
);
}
}
export default BootstrapUIController;
module.exports = BootstrapUIController;

View File

@ -16,12 +16,15 @@ import {
UiConfigSchema,
} from '../../openapi/spec/ui-config-schema';
import { OpenApiService } from '../../services/openapi-service';
import { EmailService } from '../../services/email-service';
class ConfigController extends Controller {
private versionService: VersionService;
private settingService: SettingService;
private emailService: EmailService;
private readonly openApiService: OpenApiService;
constructor(
@ -29,15 +32,20 @@ class ConfigController extends Controller {
{
versionService,
settingService,
emailService,
openApiService,
}: Pick<
IUnleashServices,
'versionService' | 'settingService' | 'openApiService'
| 'versionService'
| 'settingService'
| 'emailService'
| 'openApiService'
>,
) {
super(config);
this.versionService = versionService;
this.settingService = settingService;
this.emailService = emailService;
this.openApiService = openApiService;
this.route({
@ -71,6 +79,7 @@ class ConfigController extends Controller {
const response: UiConfigSchema = {
...this.config.ui,
version,
emailEnabled: this.emailService.isEnabled(),
unleashUrl: this.config.server.unleashUrl,
baseUriPath: this.config.server.baseUriPath,
authenticationType: this.config.authentication?.type,

View File

@ -19,6 +19,8 @@ import {
FeatureEventsSchema,
} from '../../../lib/openapi/spec/feature-events-schema';
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
const version = 1;
export default class EventController extends Controller {
@ -86,9 +88,24 @@ export default class EventController extends Controller {
}),
],
});
this.route({
method: 'post',
path: '/search',
handler: this.searchEvents,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'searchEvents',
tags: ['admin'],
requestBody: createRequestSchema('searchEventsSchema'),
responses: { 200: createResponseSchema('eventsSchema') },
}),
],
});
}
fixEvents(events: IEvent[]): IEvent[] {
maybeAnonymiseEvents(events: IEvent[]): IEvent[] {
if (this.anonymise) {
return events.map((e: IEvent) => ({
...e,
@ -105,15 +122,16 @@ export default class EventController extends Controller {
const { project } = req.query;
let events: IEvent[];
if (project) {
events = await this.eventService.getEventsForProject(project);
events = await this.eventService.searchEvents({ project });
} else {
events = await this.eventService.getEvents();
}
const response: EventsSchema = {
version,
events: serializeDates(this.fixEvents(events)),
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
@ -126,13 +144,32 @@ export default class EventController extends Controller {
req: Request<{ featureName: string }>,
res: Response<FeatureEventsSchema>,
): Promise<void> {
const toggleName = req.params.featureName;
const events = await this.eventService.getEventsForToggle(toggleName);
const feature = req.params.featureName;
const events = await this.eventService.searchEvents({ feature });
const response = {
version,
toggleName,
events: serializeDates(this.fixEvents(events)),
toggleName: feature,
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
featureEventsSchema.$id,
response,
);
}
async searchEvents(
req: Request<unknown, unknown, SearchEventsSchema>,
res: Response<EventsSchema>,
): Promise<void> {
const events = await this.eventService.searchEvents(req.body);
const response = {
version,
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(

View File

@ -13,7 +13,6 @@ import UserController from './user';
import ConfigController from './config';
import { ContextController } from './context';
import ClientMetricsController from './client-metrics';
import BootstrapUIController from './bootstrap-ui';
import StateController from './state';
import TagController from './tag';
import TagTypeController from './tag-type';
@ -68,10 +67,6 @@ class AdminApi extends Controller {
'/ui-config',
new ConfigController(config, services).router,
);
this.app.use(
'/ui-bootstrap',
new BootstrapUIController(config, services).router,
);
this.app.use(
'/context',
new ContextController(config, services).router,

View File

@ -55,7 +55,7 @@ export default class PlaygroundController extends Controller {
req: Request<any, any, PlaygroundRequestSchema>,
res: Response<PlaygroundResponseSchema>,
): Promise<void> {
const response: PlaygroundResponseSchema = {
const response = {
input: req.body,
features: await this.playgroundService.evaluateQuery(
req.body.projects,

View File

@ -3,6 +3,7 @@ import { IUnleashStores } from '../types/stores';
import { Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store';
import { IEvent } from '../types/events';
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
export default class EventService {
private logger: Logger;
@ -21,12 +22,8 @@ export default class EventService {
return this.eventStore.getEvents();
}
async getEventsForToggle(name: string): Promise<IEvent[]> {
return this.eventStore.getEventsForFeature(name);
}
async getEventsForProject(project: string): Promise<IEvent[]> {
return this.eventStore.getEventsFilterByProject(project);
async searchEvents(search: SearchEventsSchema): Promise<IEvent[]> {
return this.eventStore.searchEvents(search);
}
}

View File

@ -533,8 +533,9 @@ class FeatureToggleService {
async getClientFeatures(
query?: IFeatureToggleQuery,
includeIds?: boolean,
): Promise<FeatureConfigurationClient[]> {
return this.featureToggleClientStore.getClient(query);
return this.featureToggleClientStore.getClient(query, includeIds);
}
/**

View File

@ -176,7 +176,7 @@ export class GroupService {
}
async validateGroup(
{ name, users }: IGroupModel,
{ name }: IGroupModel,
existingGroup?: IGroup,
): Promise<void> {
if (!name) {
@ -188,10 +188,6 @@ export class GroupService {
throw new NameExistsError('Group name already exists');
}
}
if (users.length == 0 || !users.some((u) => u.role == 'Owner')) {
throw new BadDataError('Group needs to have at least one Owner');
}
}
async getRolesForProject(projectId: string): Promise<IGroupRole[]> {
@ -215,7 +211,6 @@ export class GroupService {
return {
user: user,
joinedAt: roleUser.joinedAt,
role: roleUser.role,
};
});
return { ...group, users: finalUsers };

View File

@ -89,6 +89,7 @@ export const createServices = (
const clientSpecService = new ClientSpecService(config);
const playgroundService = new PlaygroundService(config, {
featureToggleServiceV2,
segmentService,
});
return {

View File

@ -59,7 +59,7 @@ export class OpenApiService {
validation: err.validationErrors,
});
} else {
next();
next(err);
}
});
}

View File

@ -5,21 +5,28 @@ import { ALL } from '../../lib/types/models/api-token';
import { PlaygroundFeatureSchema } from 'lib/openapi/spec/playground-feature-schema';
import { Logger } from '../logger';
import { IUnleashConfig } from 'lib/types';
import { offlineUnleashClient } from '..//util/offline-unleash-client';
import { offlineUnleashClient } from '../util/offline-unleash-client';
import { FeatureInterface } from 'lib/util/feature-evaluator/feature';
import { FeatureStrategiesEvaluationResult } from 'lib/util/feature-evaluator/client';
import { SegmentService } from './segment-service';
export class PlaygroundService {
private readonly logger: Logger;
private readonly featureToggleService: FeatureToggleService;
private readonly segmentService: SegmentService;
constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
}: Pick<IUnleashServices, 'featureToggleServiceV2'>,
segmentService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'segmentService'>,
) {
this.logger = config.getLogger('services/playground-service.ts');
this.featureToggleService = featureToggleServiceV2;
this.segmentService = segmentService;
}
async evaluateQuery(
@ -27,26 +34,33 @@ export class PlaygroundService {
environment: string,
context: SdkContextSchema,
): Promise<PlaygroundFeatureSchema[]> {
const toggles = await this.featureToggleService.getClientFeatures({
project: projects === ALL ? undefined : projects,
environment,
});
const [features, segments] = await Promise.all([
this.featureToggleService.getClientFeatures(
{
project: projects === ALL ? undefined : projects,
environment,
},
true,
),
this.segmentService.getActive(),
]);
const [head, ...rest] = toggles;
const [head, ...rest] = features;
if (!head) {
return [];
} else {
const variantsMap = toggles.reduce((acc, feature) => {
const client = await offlineUnleashClient({
features: [head, ...rest],
context,
logError: this.logger.error,
segments,
});
const variantsMap = features.reduce((acc, feature) => {
acc[feature.name] = feature.variants;
return acc;
}, {});
const client = await offlineUnleashClient(
[head, ...rest],
context,
this.logger.error,
);
const clientContext = {
...context,
currentTime: context.currentTime
@ -54,20 +68,35 @@ export class PlaygroundService {
: undefined,
};
const output: PlaygroundFeatureSchema[] = await Promise.all(
client.getFeatureToggleDefinitions().map(async (feature) => {
return {
isEnabled: client.isEnabled(
feature.name,
clientContext,
),
projectId: await this.featureToggleService.getProjectId(
feature.name,
),
variant: client.getVariant(feature.name, clientContext),
name: feature.name,
variants: variantsMap[feature.name] || [],
};
}),
client
.getFeatureToggleDefinitions()
.map(async (feature: FeatureInterface) => {
const strategyEvaluationResult: FeatureStrategiesEvaluationResult =
client.isEnabled(feature.name, clientContext);
const isEnabled =
strategyEvaluationResult.result === true &&
feature.enabled;
return {
isEnabled,
isEnabledInCurrentEnvironment: feature.enabled,
strategies: {
result: strategyEvaluationResult.result,
data: strategyEvaluationResult.strategies,
},
projectId:
await this.featureToggleService.getProjectId(
feature.name,
),
variant: client.getVariant(
feature.name,
clientContext,
),
name: feature.name,
variants: variantsMap[feature.name] || [],
};
}),
);
return output;

View File

@ -13,8 +13,8 @@ export interface IGroup {
export interface IGroupUser {
groupId: number;
userId: number;
role: string;
joinedAt: Date;
seenAt?: Date;
}
export interface IGroupRole {
@ -36,7 +36,6 @@ export interface IGroupProject {
export interface IGroupUserModel {
user: IUser;
role: string;
joinedAt?: Date;
}

View File

@ -1,12 +1,11 @@
import EventEmitter from 'events';
import { IBaseEvent, IEvent } from '../events';
import { Store } from './store';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
store(event: IBaseEvent): Promise<void>;
batchStore(events: IBaseEvent[]): Promise<void>;
getEvents(): Promise<IEvent[]>;
getEventsFilterByType(name: string): Promise<IEvent[]>;
getEventsForFeature(featureName: string): Promise<IEvent[]>;
getEventsFilterByProject(project: string): Promise<IEvent[]>;
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
}

View File

@ -3,6 +3,7 @@ import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
export interface IFeatureToggleClientStore {
getClient(
featureQuery: Partial<IFeatureToggleQuery>,
includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]>;
// @Deprecated

View File

@ -40,11 +40,6 @@ export interface IGroupStore extends Store<IGroup, number> {
userName: string,
): Promise<void>;
updateExistingUsersInGroup(
groupId: number,
users: IGroupUserModel[],
): Promise<void>;
existsWithName(name: string): Promise<boolean>;
create(group: IStoreGroup): Promise<IGroup>;

View File

@ -0,0 +1,227 @@
import { Strategy } from './strategy';
import { FeatureInterface } from './feature';
import { RepositoryInterface } from './repository';
import {
Variant,
getDefaultVariant,
VariantDefinition,
selectVariant,
} from './variant';
import { Context } from './context';
import { SegmentForEvaluation } from './strategy/strategy';
import { PlaygroundStrategySchema } from 'lib/openapi/spec/playground-strategy-schema';
import { playgroundStrategyEvaluation } from '../../openapi/spec/playground-strategy-schema';
export type StrategyEvaluationResult = Pick<
PlaygroundStrategySchema,
'result' | 'segments' | 'constraints'
>;
export type FeatureStrategiesEvaluationResult = {
result: boolean | typeof playgroundStrategyEvaluation.unknownResult;
strategies: PlaygroundStrategySchema[];
};
export default class UnleashClient {
private repository: RepositoryInterface;
private strategies: Strategy[];
constructor(repository: RepositoryInterface, strategies: Strategy[]) {
this.repository = repository;
this.strategies = strategies || [];
this.strategies.forEach((strategy: Strategy) => {
if (
!strategy ||
!strategy.name ||
typeof strategy.name !== 'string' ||
!strategy.isEnabled ||
typeof strategy.isEnabled !== 'function'
) {
throw new Error('Invalid strategy data / interface');
}
});
}
private getStrategy(name: string): Strategy | undefined {
return this.strategies.find(
(strategy: Strategy): boolean => strategy.name === name,
);
}
isEnabled(
name: string,
context: Context,
fallback: Function,
): FeatureStrategiesEvaluationResult {
const feature = this.repository.getToggle(name);
return this.isFeatureEnabled(feature, context, fallback);
}
isFeatureEnabled(
feature: FeatureInterface,
context: Context,
fallback: Function,
): FeatureStrategiesEvaluationResult {
if (!feature) {
return fallback();
}
if (!Array.isArray(feature.strategies)) {
return {
result: false,
strategies: [],
};
}
if (feature.strategies.length === 0) {
return {
result: feature.enabled,
strategies: [],
};
}
const strategies = feature.strategies.map(
(strategySelector): PlaygroundStrategySchema => {
const getStrategy = () => {
// the application hostname strategy relies on external
// variables to calculate its result. As such, we can't
// evaluate it in a way that makes sense. So we'll
// use the 'unknown' strategy instead.
if (strategySelector.name === 'applicationHostname') {
return this.getStrategy('unknown');
}
return (
this.getStrategy(strategySelector.name) ??
this.getStrategy('unknown')
);
};
const strategy = getStrategy();
const segments =
strategySelector.segments
?.map(this.getSegment(this.repository))
.filter(Boolean) ?? [];
return {
name: strategySelector.name,
id: strategySelector.id,
parameters: strategySelector.parameters,
...strategy.isEnabledWithConstraints(
strategySelector.parameters,
context,
strategySelector.constraints,
segments,
),
};
},
);
// Feature evaluation
const overallStrategyResult = () => {
// if at least one strategy is enabled, then the feature is enabled
if (
strategies.some((strategy) => strategy.result.enabled === true)
) {
return true;
}
// if at least one strategy is unknown, then the feature _may_ be enabled
if (
strategies.some(
(strategy) => strategy.result.enabled === 'unknown',
)
) {
return playgroundStrategyEvaluation.unknownResult;
}
return false;
};
const evalResults: FeatureStrategiesEvaluationResult = {
result: overallStrategyResult(),
strategies,
};
return evalResults;
}
getSegment(repo: RepositoryInterface) {
return (segmentId: number): SegmentForEvaluation | undefined => {
const segment = repo.getSegment(segmentId);
if (!segment) {
return undefined;
}
return {
name: segment.name,
id: segmentId,
constraints: segment.constraints,
};
};
}
getVariant(
name: string,
context: Context,
fallbackVariant?: Variant,
): Variant {
return this.resolveVariant(name, context, true, fallbackVariant);
}
// This function is intended to close an issue in the proxy where feature enabled
// state gets checked twice when resolving a variant with random stickiness and
// gradual rollout. This is not intended for general use, prefer getVariant instead
forceGetVariant(
name: string,
context: Context,
fallbackVariant?: Variant,
): Variant {
return this.resolveVariant(name, context, false, fallbackVariant);
}
private resolveVariant(
name: string,
context: Context,
checkToggle: boolean,
fallbackVariant?: Variant,
): Variant {
const fallback = fallbackVariant || getDefaultVariant();
const feature = this.repository.getToggle(name);
if (
typeof feature === 'undefined' ||
!feature.variants ||
!Array.isArray(feature.variants) ||
feature.variants.length === 0 ||
!feature.enabled
) {
return fallback;
}
let enabled = true;
if (checkToggle) {
enabled =
this.isFeatureEnabled(feature, context, () =>
fallbackVariant ? fallbackVariant.enabled : false,
).result === true;
if (!enabled) {
return fallback;
}
}
const variant: VariantDefinition | null = selectVariant(
feature,
context,
);
if (variant === null) {
return fallback;
}
return {
name: variant.name,
payload: variant.payload,
enabled: !checkToggle || enabled,
};
}
}

View File

@ -0,0 +1,154 @@
import { gt as semverGt, lt as semverLt, eq as semverEq } from 'semver';
import { Context } from './context';
import { resolveContextValue } from './helpers';
export interface Constraint {
contextName: string;
operator: Operator;
inverted: boolean;
values: string[];
value?: string | number | Date;
caseInsensitive?: boolean;
}
export enum Operator {
IN = 'IN',
NOT_IN = 'NOT_IN',
STR_ENDS_WITH = 'STR_ENDS_WITH',
STR_STARTS_WITH = 'STR_STARTS_WITH',
STR_CONTAINS = 'STR_CONTAINS',
NUM_EQ = 'NUM_EQ',
NUM_GT = 'NUM_GT',
NUM_GTE = 'NUM_GTE',
NUM_LT = 'NUM_LT',
NUM_LTE = 'NUM_LTE',
DATE_AFTER = 'DATE_AFTER',
DATE_BEFORE = 'DATE_BEFORE',
SEMVER_EQ = 'SEMVER_EQ',
SEMVER_GT = 'SEMVER_GT',
SEMVER_LT = 'SEMVER_LT',
}
export type OperatorImpl = (
constraint: Constraint,
context: Context,
) => boolean;
const cleanValues = (values: string[]) =>
values.filter((v) => !!v).map((v) => v.trim());
const InOperator = (constraint: Constraint, context: Context) => {
const field = constraint.contextName;
const values = cleanValues(constraint.values);
const contextValue = resolveContextValue(context, field);
const isIn = values.some((val) => val === contextValue);
return constraint.operator === Operator.IN ? isIn : !isIn;
};
const StringOperator = (constraint: Constraint, context: Context) => {
const { contextName, operator, caseInsensitive } = constraint;
let values = cleanValues(constraint.values);
let contextValue = resolveContextValue(context, contextName);
if (caseInsensitive) {
values = values.map((v) => v.toLocaleLowerCase());
contextValue = contextValue?.toLocaleLowerCase();
}
if (operator === Operator.STR_STARTS_WITH) {
return values.some((val) => contextValue?.startsWith(val));
}
if (operator === Operator.STR_ENDS_WITH) {
return values.some((val) => contextValue?.endsWith(val));
}
if (operator === Operator.STR_CONTAINS) {
return values.some((val) => contextValue?.includes(val));
}
return false;
};
const SemverOperator = (constraint: Constraint, context: Context) => {
const { contextName, operator } = constraint;
const value = constraint.value as string;
const contextValue = resolveContextValue(context, contextName);
if (!contextValue) {
return false;
}
try {
if (operator === Operator.SEMVER_EQ) {
return semverEq(contextValue, value);
}
if (operator === Operator.SEMVER_LT) {
return semverLt(contextValue, value);
}
if (operator === Operator.SEMVER_GT) {
return semverGt(contextValue, value);
}
} catch (e) {
return false;
}
return false;
};
const DateOperator = (constraint: Constraint, context: Context) => {
const { operator } = constraint;
const value = new Date(constraint.value as string);
const currentTime = context.currentTime
? new Date(context.currentTime)
: new Date();
if (operator === Operator.DATE_AFTER) {
return currentTime > value;
}
if (operator === Operator.DATE_BEFORE) {
return currentTime < value;
}
return false;
};
const NumberOperator = (constraint: Constraint, context: Context) => {
const field = constraint.contextName;
const { operator } = constraint;
const value = Number(constraint.value);
const contextValue = Number(resolveContextValue(context, field));
if (Number.isNaN(value) || Number.isNaN(contextValue)) {
return false;
}
if (operator === Operator.NUM_EQ) {
return contextValue === value;
}
if (operator === Operator.NUM_GT) {
return contextValue > value;
}
if (operator === Operator.NUM_GTE) {
return contextValue >= value;
}
if (operator === Operator.NUM_LT) {
return contextValue < value;
}
if (operator === Operator.NUM_LTE) {
return contextValue <= value;
}
return false;
};
export const operators = new Map<Operator, OperatorImpl>();
operators.set(Operator.IN, InOperator);
operators.set(Operator.NOT_IN, InOperator);
operators.set(Operator.STR_STARTS_WITH, StringOperator);
operators.set(Operator.STR_ENDS_WITH, StringOperator);
operators.set(Operator.STR_CONTAINS, StringOperator);
operators.set(Operator.NUM_EQ, NumberOperator);
operators.set(Operator.NUM_LT, NumberOperator);
operators.set(Operator.NUM_LTE, NumberOperator);
operators.set(Operator.NUM_GT, NumberOperator);
operators.set(Operator.NUM_GTE, NumberOperator);
operators.set(Operator.DATE_AFTER, DateOperator);
operators.set(Operator.DATE_BEFORE, DateOperator);
operators.set(Operator.SEMVER_EQ, SemverOperator);
operators.set(Operator.SEMVER_GT, SemverOperator);
operators.set(Operator.SEMVER_LT, SemverOperator);

View File

@ -0,0 +1,14 @@
export interface Properties {
[key: string]: string | undefined | number;
}
export interface Context {
[key: string]: string | Date | undefined | number | Properties;
currentTime?: Date;
userId?: string;
sessionId?: string;
remoteAddress?: string;
environment?: string;
appName?: string;
properties?: Properties;
}

View File

@ -0,0 +1,125 @@
import Client, { FeatureStrategiesEvaluationResult } from './client';
import Repository, { RepositoryInterface } from './repository';
import { Context } from './context';
import { Strategy, defaultStrategies } from './strategy';
import { ClientFeaturesResponse, FeatureInterface } from './feature';
import { Variant } from './variant';
import { FallbackFunction, createFallbackFunction } from './helpers';
import {
BootstrapOptions,
resolveBootstrapProvider,
} from './repository/bootstrap-provider';
import { StorageProvider } from './repository/storage-provider';
export { Strategy };
export interface FeatureEvaluatorConfig {
appName: string;
environment?: string;
strategies?: Strategy[];
repository?: RepositoryInterface;
bootstrap?: BootstrapOptions;
storageProvider?: StorageProvider<ClientFeaturesResponse>;
}
export interface StaticContext {
appName: string;
environment: string;
}
export class FeatureEvaluator {
private repository: RepositoryInterface;
private client: Client;
private staticContext: StaticContext;
constructor({
appName,
environment = 'default',
strategies = [],
repository,
bootstrap = { data: [] },
storageProvider,
}: FeatureEvaluatorConfig) {
this.staticContext = { appName, environment };
const bootstrapProvider = resolveBootstrapProvider(bootstrap);
this.repository =
repository ||
new Repository({
appName,
bootstrapProvider,
storageProvider: storageProvider,
});
// setup client
const supportedStrategies = strategies.concat(defaultStrategies);
this.client = new Client(this.repository, supportedStrategies);
}
async start(): Promise<void> {
return this.repository.start();
}
destroy(): void {
this.repository.stop();
}
isEnabled(
name: string,
context?: Context,
fallbackFunction?: FallbackFunction,
): FeatureStrategiesEvaluationResult;
isEnabled(
name: string,
context?: Context,
fallbackValue?: boolean,
): FeatureStrategiesEvaluationResult;
isEnabled(
name: string,
context: Context = {},
fallback?: FallbackFunction | boolean,
): FeatureStrategiesEvaluationResult {
const enhancedContext = { ...this.staticContext, ...context };
const fallbackFunc = createFallbackFunction(
name,
enhancedContext,
fallback,
);
return this.client.isEnabled(name, enhancedContext, fallbackFunc);
}
getVariant(
name: string,
context: Context = {},
fallbackVariant?: Variant,
): Variant {
const enhancedContext = { ...this.staticContext, ...context };
return this.client.getVariant(name, enhancedContext, fallbackVariant);
}
forceGetVariant(
name: string,
context: Context = {},
fallbackVariant?: Variant,
): Variant {
const enhancedContext = { ...this.staticContext, ...context };
return this.client.forceGetVariant(
name,
enhancedContext,
fallbackVariant,
);
}
getFeatureToggleDefinition(toggleName: string): FeatureInterface {
return this.repository.getToggle(toggleName);
}
getFeatureToggleDefinitions(): FeatureInterface[] {
return this.repository.getToggles();
}
}

View File

@ -0,0 +1,22 @@
import { StrategyTransportInterface } from './strategy';
import { Segment } from './strategy/strategy';
// eslint-disable-next-line import/no-cycle
import { VariantDefinition } from './variant';
export interface FeatureInterface {
name: string;
type: string;
description?: string;
enabled: boolean;
stale: boolean;
impressionData: boolean;
strategies: StrategyTransportInterface[];
variants: VariantDefinition[];
}
export interface ClientFeaturesResponse {
version: number;
features: FeatureInterface[];
query?: any;
segments?: Segment[];
}

View File

@ -0,0 +1,40 @@
import { FeatureStrategiesEvaluationResult } from './client';
import { Context } from './context';
export type FallbackFunction = (name: string, context: Context) => boolean;
export function createFallbackFunction(
name: string,
context: Context,
fallback?: FallbackFunction | boolean,
): () => FeatureStrategiesEvaluationResult {
const createEvalResult = (enabled: boolean) => ({
result: enabled,
strategies: [],
});
if (typeof fallback === 'function') {
return () => createEvalResult(fallback(name, context));
}
if (typeof fallback === 'boolean') {
return () => createEvalResult(fallback);
}
return () => createEvalResult(false);
}
export function resolveContextValue(
context: Context,
field: string,
): string | undefined {
if (context[field]) {
return context[field] as string;
}
if (context.properties && context.properties[field]) {
return context.properties[field] as string;
}
return undefined;
}
export function safeName(str: string = ''): string {
return str.replace(/\//g, '_');
}

View File

@ -0,0 +1,10 @@
import { FeatureEvaluator, FeatureEvaluatorConfig } from './feature-evaluator';
import { Variant } from './variant';
import { Context } from './context';
import { ClientFeaturesResponse } from './feature';
import InMemStorageProvider from './repository/storage-provider-in-mem';
// exports
export { Strategy } from './strategy/index';
export { Context, Variant, FeatureEvaluator, InMemStorageProvider };
export type { ClientFeaturesResponse, FeatureEvaluatorConfig };

View File

@ -0,0 +1,39 @@
import { ClientFeaturesResponse, FeatureInterface } from '../feature';
import { Segment } from '../strategy/strategy';
export interface BootstrapProvider {
readBootstrap(): Promise<ClientFeaturesResponse | undefined>;
}
export interface BootstrapOptions {
data: FeatureInterface[];
segments?: Segment[];
}
export class DefaultBootstrapProvider implements BootstrapProvider {
private data?: FeatureInterface[];
private segments?: Segment[];
constructor(options: BootstrapOptions) {
this.data = options.data;
this.segments = options.segments;
}
async readBootstrap(): Promise<ClientFeaturesResponse | undefined> {
if (this.data) {
return {
version: 2,
segments: this.segments,
features: [...this.data],
};
}
return undefined;
}
}
export function resolveBootstrapProvider(
options: BootstrapOptions,
): BootstrapProvider {
return new DefaultBootstrapProvider(options);
}

View File

@ -0,0 +1,114 @@
import { ClientFeaturesResponse, FeatureInterface } from '../feature';
import { BootstrapProvider } from './bootstrap-provider';
import { StorageProvider } from './storage-provider';
import { Segment } from '../strategy/strategy';
export interface RepositoryInterface {
getToggle(name: string): FeatureInterface;
getToggles(): FeatureInterface[];
getSegment(id: number): Segment | undefined;
stop(): void;
start(): Promise<void>;
}
export interface RepositoryOptions {
appName: string;
bootstrapProvider: BootstrapProvider;
storageProvider: StorageProvider<ClientFeaturesResponse>;
}
interface FeatureToggleData {
[key: string]: FeatureInterface;
}
export default class Repository {
private timer: NodeJS.Timer | undefined;
private appName: string;
private bootstrapProvider: BootstrapProvider;
private storageProvider: StorageProvider<ClientFeaturesResponse>;
private data: FeatureToggleData = {};
private segments: Map<number, Segment>;
constructor({
appName,
bootstrapProvider,
storageProvider,
}: RepositoryOptions) {
this.appName = appName;
this.bootstrapProvider = bootstrapProvider;
this.storageProvider = storageProvider;
this.segments = new Map();
}
start(): Promise<void> {
return this.loadBootstrap();
}
createSegmentLookup(segments: Segment[] | undefined): Map<number, Segment> {
if (!segments) {
return new Map();
}
return new Map(segments.map((segment) => [segment.id, segment]));
}
async save(response: ClientFeaturesResponse): Promise<void> {
this.data = this.convertToMap(response.features);
this.segments = this.createSegmentLookup(response.segments);
await this.storageProvider.set(this.appName, response);
}
notEmpty(content: ClientFeaturesResponse): boolean {
return content.features.length > 0;
}
async loadBootstrap(): Promise<void> {
try {
const content = await this.bootstrapProvider.readBootstrap();
if (content && this.notEmpty(content)) {
await this.save(content);
}
} catch (err: any) {
// intentionally left empty
}
}
private convertToMap(features: FeatureInterface[]): FeatureToggleData {
const obj = features.reduce(
(
o: { [s: string]: FeatureInterface },
feature: FeatureInterface,
) => {
const a = { ...o };
a[feature.name] = feature;
return a;
},
{} as { [s: string]: FeatureInterface },
);
return obj;
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
}
}
getSegment(segmentId: number): Segment | undefined {
return this.segments.get(segmentId);
}
getToggle(name: string): FeatureInterface {
return this.data[name];
}
getToggles(): FeatureInterface[] {
return Object.keys(this.data).map((key) => this.data[key]);
}
}

View File

@ -0,0 +1,14 @@
import { StorageProvider } from './storage-provider';
export default class InMemStorageProvider<T> implements StorageProvider<T> {
private store: Map<string, T> = new Map<string, T>();
async set(key: string, data: T): Promise<void> {
this.store.set(key, data);
return Promise.resolve();
}
async get(key: string): Promise<T | undefined> {
return Promise.resolve(this.store.get(key));
}
}

View File

@ -0,0 +1,60 @@
import { join } from 'path';
import { promises } from 'fs';
import { safeName } from '../helpers';
const { writeFile, readFile } = promises;
export interface StorageProvider<T> {
set(key: string, data: T): Promise<void>;
get(key: string): Promise<T | undefined>;
}
export interface StorageOptions {
backupPath: string;
}
export class FileStorageProvider<T> implements StorageProvider<T> {
private backupPath: string;
constructor(backupPath: string) {
if (!backupPath) {
throw new Error('backup Path is required');
}
this.backupPath = backupPath;
}
private getPath(key: string): string {
return join(this.backupPath, `/unleash-backup-${safeName(key)}.json`);
}
async set(key: string, data: T): Promise<void> {
return writeFile(this.getPath(key), JSON.stringify(data));
}
async get(key: string): Promise<T | undefined> {
const path = this.getPath(key);
let data;
try {
data = await readFile(path, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
} else {
return undefined;
}
}
if (!data || data.trim().length === 0) {
return undefined;
}
try {
return JSON.parse(data);
} catch (error: any) {
if (error instanceof Error) {
error.message = `Unleash storage failed parsing file ${path}: ${error.message}`;
}
throw error;
}
}
}

View File

@ -0,0 +1,26 @@
import { hostname } from 'os';
import { Strategy } from './strategy';
export default class ApplicationHostnameStrategy extends Strategy {
private hostname: string;
constructor() {
super('applicationHostname');
this.hostname = (
process.env.HOSTNAME ||
hostname() ||
'undefined'
).toLowerCase();
}
isEnabled(parameters: { hostNames: string }): boolean {
if (!parameters.hostNames) {
return false;
}
return parameters.hostNames
.toLowerCase()
.split(/\s*,\s*/)
.includes(this.hostname);
}
}

View File

@ -0,0 +1,11 @@
import { Strategy } from './strategy';
export default class DefaultStrategy extends Strategy {
constructor() {
super('default');
}
isEnabled(): boolean {
return true;
}
}

View File

@ -0,0 +1,60 @@
import { Strategy } from './strategy';
import { Context } from '../context';
import normalizedValue from './util';
import { resolveContextValue } from '../helpers';
const STICKINESS = {
default: 'default',
random: 'random',
};
export default class FlexibleRolloutStrategy extends Strategy {
private randomGenerator: Function = () =>
`${Math.round(Math.random() * 100) + 1}`;
constructor(radnomGenerator?: Function) {
super('flexibleRollout');
if (radnomGenerator) {
this.randomGenerator = radnomGenerator;
}
}
resolveStickiness(stickiness: string, context: Context): any {
switch (stickiness) {
case STICKINESS.default:
return (
context.userId ||
context.sessionId ||
this.randomGenerator()
);
case STICKINESS.random:
return this.randomGenerator();
default:
return resolveContextValue(context, stickiness);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
isEnabled(
parameters: {
groupId?: string;
rollout: number | string;
stickiness?: string;
},
context: Context,
): boolean {
const groupId: string =
parameters.groupId ||
(context.featureToggle && String(context.featureToggle)) ||
'';
const percentage = Number(parameters.rollout);
const stickiness: string = parameters.stickiness || STICKINESS.default;
const stickinessId = this.resolveStickiness(stickiness, context);
if (!stickinessId) {
return false;
}
const normalizedUserId = normalizedValue(stickinessId, groupId);
return percentage > 0 && normalizedUserId <= percentage;
}
}

View File

@ -0,0 +1,22 @@
import { Strategy } from './strategy';
import { Context } from '../context';
export default class GradualRolloutRandomStrategy extends Strategy {
private randomGenerator: Function = () =>
Math.floor(Math.random() * 100) + 1;
constructor(randomGenerator?: Function) {
super('gradualRolloutRandom');
this.randomGenerator = randomGenerator || this.randomGenerator;
}
isEnabled(
parameters: { percentage: number | string },
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: Context,
): boolean {
const percentage: number = Number(parameters.percentage);
const random: number = this.randomGenerator();
return percentage >= random;
}
}

View File

@ -0,0 +1,26 @@
import { Strategy } from './strategy';
import normalizedValue from './util';
import { Context } from '../context';
export default class GradualRolloutSessionIdStrategy extends Strategy {
constructor() {
super('gradualRolloutSessionId');
}
isEnabled(
parameters: { percentage: number | string; groupId?: string },
context: Context,
): boolean {
const { sessionId } = context;
if (!sessionId) {
return false;
}
const percentage = Number(parameters.percentage);
const groupId = parameters.groupId || '';
const normalizedId = normalizedValue(sessionId, groupId);
return percentage > 0 && normalizedId <= percentage;
}
}

View File

@ -0,0 +1,26 @@
import { Strategy } from './strategy';
import { Context } from '../context';
import normalizedValue from './util';
export default class GradualRolloutUserIdStrategy extends Strategy {
constructor() {
super('gradualRolloutUserId');
}
isEnabled(
parameters: { percentage: number | string; groupId?: string },
context: Context,
): boolean {
const { userId } = context;
if (!userId) {
return false;
}
const percentage = Number(parameters.percentage);
const groupId = parameters.groupId || '';
const normalizedUserId = normalizedValue(userId, groupId);
return percentage > 0 && normalizedUserId <= percentage;
}
}

View File

@ -0,0 +1,25 @@
import DefaultStrategy from './default-strategy';
import GradualRolloutRandomStrategy from './gradual-rollout-random';
import GradualRolloutUserIdStrategy from './gradual-rollout-user-id';
import GradualRolloutSessionIdStrategy from './gradual-rollout-session-id';
import UserWithIdStrategy from './user-with-id-strategy';
import RemoteAddressStrategy from './remote-address-strategy';
import FlexibleRolloutStrategy from './flexible-rollout-strategy';
import { Strategy } from './strategy';
import UnknownStrategy from './unknown-strategy';
import ApplicationHostnameStrategy from './application-hostname-strategy';
export { Strategy } from './strategy';
export { StrategyTransportInterface } from './strategy';
export const defaultStrategies: Array<Strategy> = [
new DefaultStrategy(),
new ApplicationHostnameStrategy(),
new GradualRolloutRandomStrategy(),
new GradualRolloutUserIdStrategy(),
new GradualRolloutSessionIdStrategy(),
new UserWithIdStrategy(),
new RemoteAddressStrategy(),
new FlexibleRolloutStrategy(),
new UnknownStrategy(),
];

View File

@ -0,0 +1,32 @@
import { Strategy } from './strategy';
import { Context } from '../context';
import ip from 'ip';
export default class RemoteAddressStrategy extends Strategy {
constructor() {
super('remoteAddress');
}
isEnabled(parameters: { IPs?: string }, context: Context): boolean {
if (!parameters.IPs) {
return false;
}
return parameters.IPs.split(/\s*,\s*/).some(
(range: string): Boolean => {
if (range === context.remoteAddress) {
return true;
}
if (!ip.isV6Format(range)) {
try {
return ip
.cidrSubnet(range)
.contains(context.remoteAddress);
} catch (err) {
return false;
}
}
return false;
},
);
}
}

View File

@ -0,0 +1,135 @@
import { PlaygroundConstraintSchema } from 'lib/openapi/spec/playground-constraint-schema';
import { PlaygroundSegmentSchema } from 'lib/openapi/spec/playground-segment-schema';
import { StrategyEvaluationResult } from '../client';
import { Constraint, operators } from '../constraint';
import { Context } from '../context';
export type SegmentForEvaluation = {
name: string;
id: number;
constraints: Constraint[];
};
export interface StrategyTransportInterface {
name: string;
parameters: any;
constraints: Constraint[];
segments?: number[];
id?: string;
}
export interface Segment {
id: number;
name: string;
description?: string;
constraints: Constraint[];
createdBy: string;
createdAt: string;
}
export class Strategy {
public name: string;
private returnValue: boolean;
constructor(name: string, returnValue: boolean = false) {
this.name = name || 'unknown';
this.returnValue = returnValue;
}
checkConstraint(constraint: Constraint, context: Context): boolean {
const evaluator = operators.get(constraint.operator);
if (!evaluator) {
return false;
}
if (constraint.inverted) {
return !evaluator(constraint, context);
}
return evaluator(constraint, context);
}
checkConstraints(
context: Context,
constraints?: Iterable<Constraint>,
): { result: boolean; constraints: PlaygroundConstraintSchema[] } {
if (!constraints) {
return {
result: true,
constraints: [],
};
}
const mappedConstraints = [];
for (const constraint of constraints) {
if (constraint) {
mappedConstraints.push({
...constraint,
value: constraint?.value?.toString() ?? undefined,
result: this.checkConstraint(constraint, context),
});
}
}
const result = mappedConstraints.every(
(constraint) => constraint.result,
);
return {
result,
constraints: mappedConstraints,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(parameters: unknown, context: Context): boolean {
return this.returnValue;
}
checkSegments(
context: Context,
segments: SegmentForEvaluation[],
): { result: boolean; segments: PlaygroundSegmentSchema[] } {
const resolvedSegments = segments.map((segment) => {
const { result, constraints } = this.checkConstraints(
context,
segment.constraints,
);
return {
name: segment.name,
id: segment.id,
result,
constraints,
};
});
return {
result: resolvedSegments.every(
(segment) => segment.result === true,
),
segments: resolvedSegments,
};
}
isEnabledWithConstraints(
parameters: unknown,
context: Context,
constraints: Iterable<Constraint>,
segments: SegmentForEvaluation[],
): StrategyEvaluationResult {
const constraintResults = this.checkConstraints(context, constraints);
const enabledResult = this.isEnabled(parameters, context);
const segmentResults = this.checkSegments(context, segments);
const overallResult =
constraintResults.result && enabledResult && segmentResults.result;
return {
result: { enabled: overallResult, evaluationStatus: 'complete' },
constraints: constraintResults.constraints,
segments: segmentResults.segments,
};
}
}

View File

@ -0,0 +1,39 @@
import { playgroundStrategyEvaluation } from '../../../openapi/spec/playground-strategy-schema';
import { StrategyEvaluationResult } from '../client';
import { Constraint } from '../constraint';
import { Context } from '../context';
import { SegmentForEvaluation, Strategy } from './strategy';
export default class UnknownStrategy extends Strategy {
constructor() {
super('unknown');
}
isEnabled(): boolean {
return false;
}
isEnabledWithConstraints(
parameters: unknown,
context: Context,
constraints: Iterable<Constraint>,
segments: SegmentForEvaluation[],
): StrategyEvaluationResult {
const constraintResults = this.checkConstraints(context, constraints);
const segmentResults = this.checkSegments(context, segments);
const overallResult =
constraintResults.result && segmentResults.result
? playgroundStrategyEvaluation.unknownResult
: false;
return {
result: {
enabled: overallResult,
evaluationStatus: 'incomplete',
},
constraints: constraintResults.constraints,
segments: segmentResults.segments,
};
}
}

View File

@ -0,0 +1,15 @@
import { Strategy } from './strategy';
import { Context } from '../context';
export default class UserWithIdStrategy extends Strategy {
constructor() {
super('userWithId');
}
isEnabled(parameters: { userIds?: string }, context: Context): boolean {
const userIdList = parameters.userIds
? parameters.userIds.split(/\s*,\s*/)
: [];
return userIdList.includes(context.userId);
}
}

View File

@ -0,0 +1,9 @@
import * as murmurHash3 from 'murmurhash3js';
export default function normalizedValue(
id: string,
groupId: string,
normalizer = 100,
): number {
return (murmurHash3.x86.hash32(`${groupId}:${id}`) % normalizer) + 1;
}

View File

@ -0,0 +1,117 @@
import { Context } from './context';
// eslint-disable-next-line import/no-cycle
import { FeatureInterface } from './feature';
import normalizedValue from './strategy/util';
import { resolveContextValue } from './helpers';
enum PayloadType {
STRING = 'string',
}
interface Override {
contextName: string;
values: string[];
}
export interface Payload {
type: PayloadType;
value: string;
}
export interface VariantDefinition {
name: string;
weight: number;
stickiness?: string;
payload: Payload;
overrides: Override[];
}
export interface Variant {
name: string;
enabled: boolean;
payload?: Payload;
}
export function getDefaultVariant(): Variant {
return {
name: 'disabled',
enabled: false,
};
}
function randomString() {
return String(Math.round(Math.random() * 100000));
}
const stickinessSelectors = ['userId', 'sessionId', 'remoteAddress'];
function getSeed(context: Context, stickiness: string = 'default'): string {
if (stickiness !== 'default') {
const value = resolveContextValue(context, stickiness);
return value ? value.toString() : randomString();
}
let result;
stickinessSelectors.some((key: string): boolean => {
const value = context[key];
if (typeof value === 'string' && value !== '') {
result = value;
return true;
}
return false;
});
return result || randomString();
}
function overrideMatchesContext(context: Context): (o: Override) => boolean {
return (o: Override) =>
o.values.some(
(value) => value === resolveContextValue(context, o.contextName),
);
}
function findOverride(
feature: FeatureInterface,
context: Context,
): VariantDefinition | undefined {
return feature.variants
.filter((variant) => variant.overrides)
.find((variant) =>
variant.overrides.some(overrideMatchesContext(context)),
);
}
export function selectVariant(
feature: FeatureInterface,
context: Context,
): VariantDefinition | null {
const totalWeight = feature.variants.reduce((acc, v) => acc + v.weight, 0);
if (totalWeight <= 0) {
return null;
}
const variantOverride = findOverride(feature, context);
if (variantOverride) {
return variantOverride;
}
const { stickiness } = feature.variants[0];
const target = normalizedValue(
getSeed(context, stickiness),
feature.name,
totalWeight,
);
let counter = 0;
const variant = feature.variants.find(
(v: VariantDefinition): VariantDefinition | undefined => {
if (v.weight === 0) {
return undefined;
}
counter += v.weight;
if (counter < target) {
return undefined;
}
return v;
},
);
return variant || null;
}

View File

@ -1,10 +1,48 @@
import { offlineUnleashClient } from './offline-unleash-client';
import {
ClientInitOptions,
mapFeaturesForBootstrap,
mapSegmentsForBootstrap,
offlineUnleashClient,
} from './offline-unleash-client';
import {
Unleash as UnleashClientNode,
InMemStorageProvider as InMemStorageProviderNode,
} from 'unleash-client';
import { once } from 'events';
import { playgroundStrategyEvaluation } from '../openapi/spec/playground-strategy-schema';
export const offlineUnleashClientNode = async ({
features,
context,
logError,
segments,
}: ClientInitOptions): Promise<UnleashClientNode> => {
const client = new UnleashClientNode({
...context,
appName: context.appName,
disableMetrics: true,
refreshInterval: 0,
url: 'not-needed',
storageProvider: new InMemStorageProviderNode(),
bootstrap: {
data: mapFeaturesForBootstrap(features),
segments: mapSegmentsForBootstrap(segments),
},
});
client.on('error', logError);
client.start();
await once(client, 'ready');
return client;
};
describe('offline client', () => {
it('considers enabled variants with a default strategy to be on', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
const client = await offlineUnleashClient({
features: [
{
name,
enabled: true,
@ -14,19 +52,19 @@ describe('offline client', () => {
stale: false,
},
],
{ appName: 'other-app', environment: 'default' },
console.log,
);
context: { appName: 'other-app', environment: 'default' },
logError: console.log,
});
expect(client.isEnabled(name)).toBeTruthy();
expect(client.isEnabled(name).result).toBeTruthy();
});
it('constrains on appName', async () => {
const enabledFeature = 'toggle-name';
const disabledFeature = 'other-toggle';
const appName = 'app-name';
const client = await offlineUnleashClient(
[
const client = await offlineUnleashClient({
features: [
{
name: enabledFeature,
enabled: true,
@ -66,18 +104,19 @@ describe('offline client', () => {
stale: false,
},
],
{ appName, environment: 'default' },
console.log,
);
context: { appName, environment: 'default' },
logError: console.log,
});
expect(client.isEnabled(enabledFeature)).toBeTruthy();
expect(client.isEnabled(disabledFeature)).toBeFalsy();
expect(client.isEnabled(enabledFeature).result).toBeTruthy();
expect(client.isEnabled(disabledFeature).result).toBeFalsy();
});
it('considers disabled variants with a default strategy to be off', async () => {
it('considers disabled features with a default strategy to be enabled', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
const context = { appName: 'client-test' };
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
@ -91,17 +130,19 @@ describe('offline client', () => {
variants: [],
},
],
{ appName: 'client-test' },
console.log,
);
context,
logError: console.log,
});
expect(client.isEnabled(name)).toBeFalsy();
const result = client.isEnabled(name, context);
expect(result.result).toBe(true);
});
it('considers disabled variants with a default strategy and variants to be off', async () => {
it('considers disabled variants with a default strategy and variants to be on', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
@ -130,17 +171,17 @@ describe('offline client', () => {
],
},
],
{ appName: 'client-test' },
console.log,
);
context: { appName: 'client-test' },
logError: console.log,
});
expect(client.isEnabled(name)).toBeFalsy();
expect(client.isEnabled(name).result).toBe(true);
});
it("returns variant {name: 'disabled', enabled: false } if the toggle isn't enabled", async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
const client = await offlineUnleashClient({
features: [
{
strategies: [],
stale: false,
@ -165,20 +206,19 @@ describe('offline client', () => {
],
},
],
{ appName: 'client-test' },
context: { appName: 'client-test' },
logError: console.log,
});
console.log,
);
expect(client.isEnabled(name)).toBeFalsy();
expect(client.isEnabled(name).result).toBeFalsy();
expect(client.getVariant(name).name).toEqual('disabled');
expect(client.getVariant(name).enabled).toBeFalsy();
});
it('returns the disabled variant if there are no variants', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
@ -193,13 +233,200 @@ describe('offline client', () => {
variants: [],
},
],
{ appName: 'client-test' },
console.log,
);
context: { appName: 'client-test' },
logError: console.log,
});
expect(client.getVariant(name, {}).name).toEqual('disabled');
expect(client.getVariant(name, {}).enabled).toBeFalsy();
expect(client.isEnabled(name, {})).toBeTruthy();
expect(client.isEnabled(name, {}).result).toBeTruthy();
});
it(`returns '${playgroundStrategyEvaluation.unknownResult}' if it can't evaluate a feature`, async () => {
const name = 'toggle-name';
const context = { appName: 'client-test' };
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
name: 'unimplemented-custom-strategy',
constraints: [],
},
],
stale: false,
enabled: true,
name,
type: 'experiment',
variants: [],
},
],
context,
logError: console.log,
});
const result = client.isEnabled(name, context);
result.strategies.forEach((strategy) =>
expect(strategy.result.enabled).toEqual(
playgroundStrategyEvaluation.unknownResult,
),
);
expect(result.result).toEqual(
playgroundStrategyEvaluation.unknownResult,
);
});
it(`returns '${playgroundStrategyEvaluation.unknownResult}' for the application hostname strategy`, async () => {
const name = 'toggle-name';
const context = { appName: 'client-test' };
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
name: 'applicationHostname',
constraints: [],
},
],
stale: false,
enabled: true,
name,
type: 'experiment',
variants: [],
},
],
context,
logError: console.log,
});
const result = client.isEnabled(name, context);
result.strategies.forEach((strategy) =>
expect(strategy.result.enabled).toEqual(
playgroundStrategyEvaluation.unknownResult,
),
);
expect(result.result).toEqual(
playgroundStrategyEvaluation.unknownResult,
);
});
it('returns strategies in the order they are provided', async () => {
const featureName = 'featureName';
const strategies = [
{
name: 'default',
constraints: [],
parameters: {},
},
{
name: 'default',
constraints: [
{
values: ['my-app-name'],
inverted: false,
operator: 'IN' as 'IN',
contextName: 'appName',
caseInsensitive: false,
},
],
parameters: {},
},
{
name: 'applicationHostname',
constraints: [],
parameters: {
hostNames: 'myhostname.com',
},
},
{
name: 'flexibleRollout',
constraints: [],
parameters: {
groupId: 'killer',
rollout: '34',
stickiness: 'userId',
},
},
{
name: 'userWithId',
constraints: [],
parameters: {
userIds: 'uoea,ueoa',
},
},
{
name: 'remoteAddress',
constraints: [],
parameters: {
IPs: '196.6.6.05',
},
},
];
const context = { appName: 'client-test' };
const client = await offlineUnleashClient({
features: [
{
strategies,
// impressionData: false,
enabled: true,
name: featureName,
// description: '',
// project: 'heartman-for-test',
stale: false,
type: 'kill-switch',
variants: [
{
name: 'a',
weight: 334,
weightType: 'variable',
stickiness: 'default',
overrides: [],
payload: {
type: 'json',
value: '{"hello": "world"}',
},
},
{
name: 'b',
weight: 333,
weightType: 'variable',
stickiness: 'default',
overrides: [],
payload: {
type: 'string',
value: 'ueoau',
},
},
{
name: 'c',
weight: 333,
weightType: 'variable',
stickiness: 'default',
payload: {
type: 'csv',
value: '1,2,3',
},
overrides: [],
},
],
},
],
context,
logError: console.log,
});
const evaluatedStrategies = client
.isEnabled(featureName, context)
.strategies.map((strategy) => strategy.name);
expect(evaluatedStrategies).toEqual(
strategies.map((strategy) => strategy.name),
);
});
});

View File

@ -1,8 +1,11 @@
import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema';
import { InMemStorageProvider, Unleash as UnleashClient } from 'unleash-client';
import { InMemStorageProvider, FeatureEvaluator } from './feature-evaluator';
import { FeatureConfigurationClient } from 'lib/types/stores/feature-strategies-store';
import { Operator } from 'unleash-client/lib/strategy/strategy';
import { once } from 'events';
import { Segment } from './feature-evaluator/strategy/strategy';
import { ISegment } from 'lib/types/model';
import { serializeDates } from '../../lib/types/serialize-dates';
import { FeatureInterface } from './feature-evaluator/feature';
import { Operator } from './feature-evaluator/constraint';
enum PayloadType {
STRING = 'string',
@ -10,7 +13,9 @@ enum PayloadType {
type NonEmptyList<T> = [T, ...T[]];
const mapFeaturesForBootstrap = (features: FeatureConfigurationClient[]) =>
export const mapFeaturesForBootstrap = (
features: FeatureConfigurationClient[],
): FeatureInterface[] =>
features.map((feature) => ({
impressionData: false,
...feature,
@ -36,27 +41,32 @@ const mapFeaturesForBootstrap = (features: FeatureConfigurationClient[]) =>
})),
}));
export const offlineUnleashClient = async (
features: NonEmptyList<FeatureConfigurationClient>,
context: SdkContextSchema,
logError: (message: any, ...args: any[]) => void,
): Promise<UnleashClient> => {
const client = new UnleashClient({
export const mapSegmentsForBootstrap = (segments: ISegment[]): Segment[] =>
serializeDates(segments) as Segment[];
export type ClientInitOptions = {
features: NonEmptyList<FeatureConfigurationClient>;
segments?: ISegment[];
context: SdkContextSchema;
logError: (message: any, ...args: any[]) => void;
};
export const offlineUnleashClient = async ({
features,
context,
segments,
}: ClientInitOptions): Promise<FeatureEvaluator> => {
const client = new FeatureEvaluator({
...context,
appName: context.appName,
disableMetrics: true,
refreshInterval: 0,
url: 'not-needed',
storageProvider: new InMemStorageProvider(),
bootstrap: {
data: mapFeaturesForBootstrap(features),
segments: mapSegmentsForBootstrap(segments),
},
});
client.on('error', logError);
client.start();
await once(client, 'ready');
return client;
};

View File

@ -0,0 +1,19 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE group_user DROP COLUMN IF EXISTS role;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE group_user ADD COLUMN role text check(role in ('Owner', 'Member')) default 'Member';
`,
cb,
);
};

View File

@ -0,0 +1,20 @@
exports.up = function (db, cb) {
db.runSql(
`
delete from group_role where project not in (select id from projects);
ALTER TABLE group_role
ADD CONSTRAINT fk_group_role_project
FOREIGN KEY(project)
REFERENCES projects(id) ON DELETE CASCADE; `,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE group_role DROP CONSTRAINT fk_group_role_project;
`,
cb,
);
};

View File

@ -5,6 +5,7 @@ import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema';
import { IVariant, WeightType } from '../lib/types/model';
import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema';
import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema';
import { SegmentSchema } from 'lib/openapi/spec/segment-schema';
export const urlFriendlyString = (): Arbitrary<string> =>
fc
@ -28,32 +29,55 @@ export const commonISOTimestamp = (): Arbitrary<string> =>
})
.map((timestamp) => timestamp.toISOString());
export const strategyConstraint = (): Arbitrary<ConstraintSchema> =>
fc.record({
contextName: urlFriendlyString(),
operator: fc.constantFrom(...ALL_OPERATORS),
caseInsensitive: fc.boolean(),
inverted: fc.boolean(),
values: fc.array(fc.string()),
value: fc.string(),
});
const strategyConstraints = (): Arbitrary<ConstraintSchema[]> =>
fc.array(
fc.record({
contextName: urlFriendlyString(),
operator: fc.constantFrom(...ALL_OPERATORS),
caseInsensitive: fc.boolean(),
inverted: fc.boolean(),
values: fc.array(fc.string()),
value: fc.string(),
}),
);
fc.array(strategyConstraint());
export const strategy = (
name: string,
parameters: Arbitrary<Record<string, string>>,
parameters?: Arbitrary<Record<string, string>>,
): Arbitrary<FeatureStrategySchema> =>
parameters
? fc.record(
{
name: fc.constant(name),
id: fc.uuid(),
parameters,
segments: fc.uniqueArray(fc.integer({ min: 1 })),
constraints: strategyConstraints(),
},
{ requiredKeys: ['name', 'parameters', 'id'] },
)
: fc.record(
{
id: fc.uuid(),
name: fc.constant(name),
segments: fc.uniqueArray(fc.integer({ min: 1 })),
constraints: strategyConstraints(),
},
{ requiredKeys: ['name', 'id'] },
);
export const segment = (): Arbitrary<SegmentSchema> =>
fc.record({
name: fc.constant(name),
parameters,
id: fc.integer({ min: 1 }),
name: urlFriendlyString(),
constraints: strategyConstraints(),
});
export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
fc.array(
fc.uniqueArray(
fc.oneof(
strategy('default', fc.constant({})),
strategy('default'),
strategy(
'flexibleRollout',
fc.record({
@ -89,7 +113,16 @@ export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
IPs: fc.uniqueArray(fc.ipV4()).map((ips) => ips.join(',')),
}),
),
strategy(
'custom-strategy',
fc.record({
customParam: fc
.uniqueArray(fc.lorem())
.map((words) => words.join(',')),
}),
),
),
{ selector: (generatedStrategy) => generatedStrategy.id },
);
export const variant = (): Arbitrary<IVariant> =>
@ -167,6 +200,64 @@ export const clientFeatures = (constraints?: {
selector: (v) => v.name,
});
export const clientFeaturesAndSegments = (featureConstraints?: {
minLength?: number;
}): Arbitrary<{
features: ClientFeatureSchema[];
segments: SegmentSchema[];
}> => {
const segments = () =>
fc.uniqueArray(segment(), {
selector: (generatedSegment) => generatedSegment.id,
});
// create segments and make sure that all strategies reference segments that
// exist
return fc
.tuple(segments(), clientFeatures(featureConstraints))
.map(([generatedSegments, generatedFeatures]) => {
const renumberedSegments = generatedSegments.map(
(generatedSegment, index) => ({
...generatedSegment,
id: index + 1,
}),
);
const features: ClientFeatureSchema[] = generatedFeatures.map(
(feature) => ({
...feature,
...(feature.strategies && {
strategies: feature.strategies.map(
(generatedStrategy) => ({
...generatedStrategy,
...(generatedStrategy.segments && {
segments:
renumberedSegments.length > 0
? [
...new Set(
generatedStrategy.segments.map(
(generatedSegment) =>
(generatedSegment %
renumberedSegments.length) +
1,
),
),
]
: [],
}),
}),
),
}),
}),
);
return {
features,
segments: renumberedSegments,
};
});
};
// TEST ARBITRARIES
test('url-friendly strings are URL-friendly', () =>

View File

@ -1,43 +0,0 @@
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { setupAppWithAuth } from '../../helpers/test-helper';
let app;
let db;
const email = 'user@getunleash.io';
beforeAll(async () => {
db = await dbInit('ui_bootstrap_serial', getLogger);
app = await setupAppWithAuth(db.stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('Should get ui-bootstrap data', async () => {
// login
await app.request
.post('/auth/demo/login')
.send({
email,
})
.expect(200);
// get user data
await app.request
.get('/api/admin/ui-bootstrap')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
const bootstrap = res.body;
expect(bootstrap.context).toBeDefined();
expect(bootstrap.featureTypes).toBeDefined();
expect(bootstrap.uiConfig).toBeDefined();
expect(bootstrap.user).toBeDefined();
expect(bootstrap.context.length).toBeGreaterThan(0);
expect(bootstrap.user.email).toBe(email);
});
});

View File

@ -16,7 +16,7 @@ afterAll(async () => {
await db.destroy();
});
test('gets ui config', async () => {
test('gets ui config fields', async () => {
const { body } = await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
@ -24,10 +24,12 @@ test('gets ui config', async () => {
expect(body.unleashUrl).toBe('http://localhost:4242');
expect(body.version).toBeDefined();
expect(body.emailEnabled).toBe(false);
});
test('gets ui config with disablePasswordAuth', async () => {
await db.stores.settingStore.insert(simpleAuthKey, { disabled: true });
const { body } = await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)

View File

@ -1,8 +1,9 @@
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { FEATURE_CREATED } from '../../../../lib/types/events';
import { FEATURE_CREATED, IBaseEvent } from '../../../../lib/types/events';
import { IEventStore } from '../../../../lib/types/stores/event-store';
import { randomId } from '../../../../lib/util/random-id';
let app: IUnleashTest;
let db: ITestDb;
@ -14,6 +15,10 @@ beforeAll(async () => {
eventStore = db.stores.eventStore;
});
beforeEach(async () => {
await eventStore.deleteAll();
});
afterAll(async () => {
await app.destroy();
await db.destroy();
@ -60,3 +65,61 @@ test('Can filter by project', async () => {
expect(res.body.events[0].data.id).toEqual('feature');
});
});
test('can search for events', async () => {
const events: IBaseEvent[] = [
{
type: FEATURE_CREATED,
project: randomId(),
data: { id: randomId() },
tags: [],
createdBy: randomId(),
},
{
type: FEATURE_CREATED,
project: randomId(),
data: { id: randomId() },
preData: { id: randomId() },
tags: [],
createdBy: randomId(),
},
];
await Promise.all(
events.map((event) => {
return eventStore.store(event);
}),
);
await app.request
.post('/api/admin/events/search')
.send({})
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(2);
});
await app.request
.post('/api/admin/events/search')
.send({ limit: 1, offset: 1 })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].data.id).toEqual(events[0].data.id);
});
await app.request
.post('/api/admin/events/search')
.send({ query: events[1].data.id })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].data.id).toEqual(events[1].data.id);
});
await app.request
.post('/api/admin/events/search')
.send({ query: events[1].preData.id })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].preData.id).toEqual(events[1].preData.id);
});
});

View File

@ -7,9 +7,11 @@ import {
} from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../lib/util/constants';
import { FeatureSchema } from '../../../../lib/openapi/spec/feature-schema';
import { VariantSchema } from '../../../../lib/openapi/spec/variant-schema';
import { FeatureStrategySchema } from '../../../../lib/openapi/spec/feature-strategy-schema';
import {
FeatureToggleDTO,
IStrategyConfig,
IVariant,
} from '../../../../lib/types/model';
let app: IUnleashTest;
let db: ITestDb;
@ -25,8 +27,8 @@ beforeAll(async () => {
app = await setupApp(db.stores);
const createToggle = async (
toggle: Omit<FeatureSchema, 'archivedAt' | 'createdAt'>,
strategy: Omit<FeatureStrategySchema, 'id'> = defaultStrategy,
toggle: FeatureToggleDTO,
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
projectId: string = 'default',
username: string = 'test',
) => {
@ -43,7 +45,7 @@ beforeAll(async () => {
};
const createVariants = async (
featureName: string,
variants: VariantSchema[],
variants: IVariant[],
projectId: string = 'default',
username: string = 'test',
) => {
@ -58,14 +60,12 @@ beforeAll(async () => {
await createToggle({
name: 'featureX',
description: 'the #1 feature',
project: 'some-project',
});
await createToggle(
{
name: 'featureY',
description: 'soon to be the #1 feature',
project: 'some-project',
},
{
name: 'baz',
@ -80,7 +80,6 @@ beforeAll(async () => {
{
name: 'featureZ',
description: 'terrible feature',
project: 'some-project',
},
{
name: 'baz',
@ -95,7 +94,6 @@ beforeAll(async () => {
{
name: 'featureArchivedX',
description: 'the #1 feature',
project: 'some-project',
},
{
name: 'default',
@ -113,7 +111,6 @@ beforeAll(async () => {
{
name: 'featureArchivedY',
description: 'soon to be the #1 feature',
project: 'some-project',
},
{
name: 'baz',
@ -133,7 +130,6 @@ beforeAll(async () => {
{
name: 'featureArchivedZ',
description: 'terrible feature',
project: 'some-project',
},
{
name: 'baz',
@ -152,7 +148,6 @@ beforeAll(async () => {
await createToggle({
name: 'feature.with.variants',
description: 'A feature toggle with variants',
project: 'some-project',
});
await createVariants('feature.with.variants', [
{
@ -340,6 +335,15 @@ test('require new feature toggle to have a name', async () => {
.expect(400);
});
test('should return 400 on invalid JSON data', async () => {
expect.assertions(0);
return app.request
.post('/api/admin/features')
.send(`{ invalid-json }`)
.set('Content-Type', 'application/json')
.expect(400);
});
test('can not change status of feature toggle that does not exist', async () => {
expect.assertions(0);
return app.request

View File

@ -38,6 +38,7 @@ afterAll(async () => {
const reset = (database: ITestDb) => async () => {
await database.stores.featureToggleStore.deleteAll();
await database.stores.featureStrategiesStore.deleteAll();
await database.stores.environmentStore.deleteAll();
};
@ -270,6 +271,51 @@ describe('Playground API E2E', () => {
);
});
test('isEnabledInCurrentEnvironment should always match feature.enabled', async () => {
await fc.assert(
fc
.asyncProperty(
clientFeatures(),
fc.context(),
async (features, ctx) => {
await seedDatabase(db, features, 'default');
const body = await playgroundRequest(
app,
token.secret,
{
projects: ALL,
environment: 'default',
context: {
appName: 'playground-test',
},
},
);
const createDict = (xs: { name: string }[]) =>
xs.reduce(
(acc, next) => ({ ...acc, [next.name]: next }),
{},
);
const mappedToggles = createDict(body.features);
ctx.log(JSON.stringify(features));
ctx.log(JSON.stringify(mappedToggles));
return features.every(
(feature) =>
feature.enabled ===
mappedToggles[feature.name]
.isEnabledInCurrentEnvironment,
);
},
)
.afterEach(reset(db)),
testParams,
);
});
describe('context application', () => {
it('applies appName constraints correctly', async () => {
const appNames = ['A', 'B', 'C'];

View File

@ -305,111 +305,6 @@ Object {
},
"type": "object",
},
"bootstrapUiSchema": Object {
"additionalProperties": false,
"properties": Object {
"context": Object {
"items": Object {
"$ref": "#/components/schemas/contextFieldSchema",
},
"type": "array",
},
"email": Object {
"type": "boolean",
},
"featureTypes": Object {
"items": Object {
"$ref": "#/components/schemas/featureTypeSchema",
},
"type": "array",
},
"projects": Object {
"items": Object {
"$ref": "#/components/schemas/projectSchema",
},
"type": "array",
},
"strategies": Object {
"items": Object {
"$ref": "#/components/schemas/strategySchema",
},
"type": "array",
},
"tagTypes": Object {
"items": Object {
"$ref": "#/components/schemas/tagTypeSchema",
},
"type": "array",
},
"uiConfig": Object {
"$ref": "#/components/schemas/uiConfigSchema",
},
"user": Object {
"properties": Object {
"createdAt": Object {
"format": "date-time",
"type": "string",
},
"email": Object {
"type": "string",
},
"emailSent": Object {
"type": "boolean",
},
"id": Object {
"type": "number",
},
"imageUrl": Object {
"type": "string",
},
"inviteLink": Object {
"type": "string",
},
"isAPI": Object {
"type": "boolean",
},
"loginAttempts": Object {
"type": "number",
},
"name": Object {
"type": "string",
},
"permissions": Object {
"items": Object {
"$ref": "#/components/schemas/permissionSchema",
},
"type": "array",
},
"rootRole": Object {
"type": "number",
},
"seenAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"username": Object {
"type": "string",
},
},
"required": Array [
"id",
],
"type": "object",
},
},
"required": Array [
"uiConfig",
"user",
"email",
"context",
"featureTypes",
"tagTypes",
"strategies",
"projects",
],
"type": "object",
},
"changePasswordSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -513,7 +408,7 @@ Object {
},
"variants": Object {
"items": Object {
"$ref": "#/components/schemas/clientVariantSchema",
"$ref": "#/components/schemas/variantSchema",
},
"nullable": true,
"type": "array",
@ -639,43 +534,6 @@ Object {
],
"type": "object",
},
"clientVariantSchema": Object {
"additionalProperties": false,
"properties": Object {
"name": Object {
"type": "string",
},
"payload": Object {
"properties": Object {
"type": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
},
"required": Array [
"type",
"value",
],
"type": "object",
},
"stickiness": Object {
"type": "string",
},
"weight": Object {
"type": "number",
},
"weightType": Object {
"type": "string",
},
},
"required": Array [
"name",
"weight",
],
"type": "object",
},
"cloneFeatureSchema": Object {
"properties": Object {
"name": Object {
@ -692,17 +550,25 @@ Object {
},
"constraintSchema": Object {
"additionalProperties": false,
"description": "A strategy constraint. For more information, refer to [the strategy constraint reference documentation](https://docs.getunleash.io/advanced/strategy_constraints)",
"properties": Object {
"caseInsensitive": Object {
"default": false,
"description": "Whether the operator should be case sensitive or not. Defaults to \`false\` (being case sensitive).",
"type": "boolean",
},
"contextName": Object {
"description": "The name of the context field that this constraint should apply to.",
"example": "appName",
"type": "string",
},
"inverted": Object {
"default": false,
"description": "Whether the result should be negated or not. If \`true\`, will turn a \`true\` result into a \`false\` result and vice versa.",
"type": "boolean",
},
"operator": Object {
"description": "The operator to use when evaluating this constraint. For more information about the various operators, refer to [the strategy constraint operator documentation](https://docs.getunleash.io/advanced/strategy_constraints#strategy-constraint-operators).",
"enum": Array [
"NOT_IN",
"IN",
@ -723,9 +589,11 @@ Object {
"type": "string",
},
"value": Object {
"description": "The context value that should be used for constraint evaluation. Use this property instead of \`values\` for properties that only accept single values.",
"type": "string",
},
"values": Object {
"description": "The context values that should be used for constraint evaluation. Use this property instead of \`value\` for properties that accept multiple values.",
"items": Object {
"type": "string",
},
@ -1142,7 +1010,6 @@ Object {
},
},
"required": Array [
"toggleName",
"events",
],
"type": "object",
@ -1492,15 +1359,11 @@ Object {
"format": "date-time",
"type": "string",
},
"role": Object {
"type": "string",
},
"user": Object {
"$ref": "#/components/schemas/userSchema",
},
},
"required": Array [
"role",
"user",
],
"type": "object",
@ -1807,24 +1670,135 @@ Object {
],
"type": "object",
},
"playgroundConstraintSchema": Object {
"additionalProperties": false,
"description": "A strategy constraint. For more information, refer to [the strategy constraint reference documentation](https://docs.getunleash.io/advanced/strategy_constraints)",
"properties": Object {
"caseInsensitive": Object {
"default": false,
"description": "Whether the operator should be case sensitive or not. Defaults to \`false\` (being case sensitive).",
"type": "boolean",
},
"contextName": Object {
"description": "The name of the context field that this constraint should apply to.",
"example": "appName",
"type": "string",
},
"inverted": Object {
"default": false,
"description": "Whether the result should be negated or not. If \`true\`, will turn a \`true\` result into a \`false\` result and vice versa.",
"type": "boolean",
},
"operator": Object {
"description": "The operator to use when evaluating this constraint. For more information about the various operators, refer to [the strategy constraint operator documentation](https://docs.getunleash.io/advanced/strategy_constraints#strategy-constraint-operators).",
"enum": Array [
"NOT_IN",
"IN",
"STR_ENDS_WITH",
"STR_STARTS_WITH",
"STR_CONTAINS",
"NUM_EQ",
"NUM_GT",
"NUM_GTE",
"NUM_LT",
"NUM_LTE",
"DATE_AFTER",
"DATE_BEFORE",
"SEMVER_EQ",
"SEMVER_GT",
"SEMVER_LT",
],
"type": "string",
},
"result": Object {
"description": "Whether this was evaluated as true or false.",
"type": "boolean",
},
"value": Object {
"description": "The context value that should be used for constraint evaluation. Use this property instead of \`values\` for properties that only accept single values.",
"type": "string",
},
"values": Object {
"description": "The context values that should be used for constraint evaluation. Use this property instead of \`value\` for properties that accept multiple values.",
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"operator",
"result",
],
"type": "object",
},
"playgroundFeatureSchema": Object {
"additionalProperties": false,
"description": "A simplified feature toggle model intended for the Unleash playground.",
"properties": Object {
"isEnabled": Object {
"description": "Whether this feature is enabled or not in the current environment.
If a feature can't be fully evaluated (that is, \`strategies.result\` is \`unknown\`),
this will be \`false\` to align with how client SDKs treat unresolved feature states.",
"example": true,
"type": "boolean",
},
"isEnabledInCurrentEnvironment": Object {
"description": "Whether the feature is active and would be evaluated in the provided environment in a normal SDK context.",
"type": "boolean",
},
"name": Object {
"description": "The feature's name.",
"example": "my-feature",
"type": "string",
},
"projectId": Object {
"description": "The ID of the project that contains this feature.",
"example": "my-project",
"type": "string",
},
"strategies": Object {
"additionalProperties": false,
"properties": Object {
"data": Object {
"description": "The strategies that apply to this feature.",
"items": Object {
"$ref": "#/components/schemas/playgroundStrategySchema",
},
"type": "array",
},
"result": Object {
"anyOf": Array [
Object {
"type": "boolean",
},
Object {
"enum": Array [
"unknown",
],
"type": "string",
},
],
"description": "The cumulative results of all the feature's strategies. Can be \`true\`,
\`false\`, or \`unknown\`.
This property will only be \`unknown\`
if one or more of the strategies can't be fully evaluated and the rest of the strategies
all resolve to \`false\`.",
},
},
"required": Array [
"result",
"data",
],
"type": "object",
},
"variant": Object {
"additionalProperties": false,
"description": "The feature variant you receive based on the provided context or the _disabled
variant_. If a feature is disabled or doesn't have any
variants, you would get the _disabled variant_.
Otherwise, you'll get one of thefeature's defined variants.",
"example": Object {
"enabled": true,
"name": "green",
@ -1832,15 +1806,20 @@ Object {
"nullable": true,
"properties": Object {
"enabled": Object {
"description": "Whether the variant is enabled or not. If the feature is disabled or if it doesn't have variants, this property will be \`false\`",
"type": "boolean",
},
"name": Object {
"description": "The variant's name. If there is no variant or if the toggle is disabled, this will be \`disabled\`",
"example": "red-variant",
"type": "string",
},
"payload": Object {
"additionalProperties": false,
"description": "An optional payload attached to the variant.",
"properties": Object {
"type": Object {
"description": "The format of the payload.",
"enum": Array [
"json",
"csv",
@ -1849,6 +1828,8 @@ Object {
"type": "string",
},
"value": Object {
"description": "The payload value stringified.",
"example": "{\\"property\\": \\"value\\"}",
"type": "string",
},
},
@ -1876,8 +1857,10 @@ Object {
"name",
"projectId",
"isEnabled",
"isEnabledInCurrentEnvironment",
"variant",
"variants",
"strategies",
],
"type": "object",
},
@ -1886,8 +1869,10 @@ Object {
"properties": Object {
"context": Object {
"$ref": "#/components/schemas/sdkContextSchema",
"description": "The context to use when evaluating toggles",
},
"environment": Object {
"description": "The environment to evaluate toggles in.",
"example": "development",
"type": "string",
},
@ -1924,6 +1909,7 @@ Object {
"description": "The state of all features given the provided input.",
"properties": Object {
"features": Object {
"description": "The list of features that have been evaluated.",
"items": Object {
"$ref": "#/components/schemas/playgroundFeatureSchema",
},
@ -1931,6 +1917,7 @@ Object {
},
"input": Object {
"$ref": "#/components/schemas/playgroundRequestSchema",
"description": "The given input used to evaluate the features.",
},
},
"required": Array [
@ -1939,6 +1926,141 @@ Object {
],
"type": "object",
},
"playgroundSegmentSchema": Object {
"additionalProperties": false,
"properties": Object {
"constraints": Object {
"description": "The list of constraints in this segment.",
"items": Object {
"$ref": "#/components/schemas/playgroundConstraintSchema",
},
"type": "array",
},
"id": Object {
"description": "The segment's id.",
"type": "integer",
},
"name": Object {
"description": "The name of the segment.",
"example": "segment A",
"type": "string",
},
"result": Object {
"description": "Whether this was evaluated as true or false.",
"type": "boolean",
},
},
"required": Array [
"name",
"id",
"constraints",
"result",
],
"type": "object",
},
"playgroundStrategySchema": Object {
"additionalProperties": false,
"properties": Object {
"constraints": Object {
"description": "The strategy's constraints and their evaluation results.",
"items": Object {
"$ref": "#/components/schemas/playgroundConstraintSchema",
},
"type": "array",
},
"id": Object {
"description": "The strategy's id.",
"type": "string",
},
"name": Object {
"description": "The strategy's name.",
"type": "string",
},
"parameters": Object {
"$ref": "#/components/schemas/parametersSchema",
"description": "The strategy's constraints and their evaluation results.",
"example": Object {
"myParam1": "param value",
},
},
"result": Object {
"anyOf": Array [
Object {
"additionalProperties": false,
"properties": Object {
"enabled": Object {
"anyOf": Array [
Object {
"enum": Array [
false,
],
"type": "boolean",
},
Object {
"enum": Array [
"unknown",
],
"type": "string",
},
],
"description": "Whether this strategy resolves to \`false\` or if it might resolve to \`true\`. Because Unleash can't evaluate the strategy, it can't say for certain whether it will be \`true\`, but if you have failing constraints or segments, it _can_ determine that your strategy would be \`false\`.",
},
"evaluationStatus": Object {
"description": "Signals that this strategy could not be evaluated. This is most likely because you're using a custom strategy that Unleash doesn't know about.",
"enum": Array [
"incomplete",
],
"type": "string",
},
},
"required": Array [
"evaluationStatus",
"enabled",
],
"type": "object",
},
Object {
"additionalProperties": false,
"properties": Object {
"enabled": Object {
"description": "Whether this strategy evaluates to true or not.",
"type": "boolean",
},
"evaluationStatus": Object {
"description": "Signals that this strategy was evaluated successfully.",
"enum": Array [
"complete",
],
"type": "string",
},
},
"required": Array [
"evaluationStatus",
"enabled",
],
"type": "object",
},
],
"description": "The strategy's evaluation result. If the strategy is a custom strategy that Unleash can't evaluate, \`evaluationStatus\` will be \`unknown\`. Otherwise, it will be \`true\` or \`false\`",
},
"segments": Object {
"description": "The strategy's segments and their evaluation results.",
"items": Object {
"$ref": "#/components/schemas/playgroundSegmentSchema",
},
"type": "array",
},
},
"required": Array [
"id",
"name",
"result",
"segments",
"constraints",
"parameters",
],
"type": "object",
},
"projectEnvironmentSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -2091,6 +2213,47 @@ Object {
],
"type": "object",
},
"searchEventsSchema": Object {
"description": "
Search for events by type, project, feature, free-text query,
or a combination thereof. Pass an empty object to fetch all events.
",
"properties": Object {
"feature": Object {
"description": "Find events by feature toggle name (case-sensitive).",
"type": "string",
},
"limit": Object {
"default": 100,
"maximum": 100,
"minimum": 1,
"type": "integer",
},
"offset": Object {
"default": 0,
"minimum": 0,
"type": "integer",
},
"project": Object {
"description": "Find events by project ID (case-sensitive).",
"type": "string",
},
"query": Object {
"description": "
Find events by a free-text search query.
The query will be matched against the event type,
the username or email that created the event (if any),
and the event data payload (if any).
",
"type": "string",
},
"type": Object {
"description": "Find events by event type (case-sensitive).",
"type": "string",
},
},
"type": "object",
},
"segmentSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -2452,6 +2615,9 @@ Object {
"disablePasswordAuth": Object {
"type": "boolean",
},
"emailEnabled": Object {
"type": "boolean",
},
"environment": Object {
"type": "string",
},
@ -2814,8 +2980,6 @@ Object {
"required": Array [
"name",
"weight",
"weightType",
"stickiness",
],
"type": "object",
},
@ -3608,6 +3772,37 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/events/search": Object {
"post": Object {
"operationId": "searchEvents",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/searchEventsSchema",
},
},
},
"description": "searchEventsSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/eventsSchema",
},
},
},
"description": "eventsSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/events/{featureName}": Object {
"get": Object {
"description": "Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.",
@ -5773,26 +5968,6 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/ui-bootstrap": Object {
"get": Object {
"operationId": "getBootstrapUiData",
"responses": Object {
"202": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/bootstrapUiSchema",
},
},
},
"description": "bootstrapUiSchema",
},
},
"tags": Array [
"other",
],
},
},
"/api/admin/ui-config": Object {
"get": Object {
"operationId": "getUIConfig",

View File

@ -170,7 +170,7 @@ const hasCommonProjectAccess = async (user, projectName, condition) => {
).toBe(condition);
};
const hasFullProjectAccess = async (user, projectName, condition) => {
const hasFullProjectAccess = async (user, projectName: string, condition) => {
const { DELETE_PROJECT, UPDATE_PROJECT, MOVE_FEATURE_TOGGLE } = permissions;
expect(
@ -862,13 +862,19 @@ test('Should not be allowed to delete a project role', async () => {
});
test('Should be allowed move feature toggle to project when given access through group', async () => {
const project = 'yet-another-project';
const project = {
id: 'yet-another-project1',
name: 'yet-another-project1',
};
const groupStore = stores.groupStore;
const viewerUser = await createUserViewerAccess(
'Victoria Viewer',
'vickyv@getunleash.io',
);
await projectService.createProject(project, editorUser);
const groupWithProjectAccess = await groupStore.create({
name: 'Project Editors',
description: '',
@ -876,30 +882,35 @@ test('Should be allowed move feature toggle to project when given access through
await groupStore.addNewUsersToGroup(
groupWithProjectAccess.id,
[{ user: viewerUser, role: 'Owner' }],
[{ user: viewerUser }],
'Admin',
);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
await hasCommonProjectAccess(viewerUser, project, false);
await hasCommonProjectAccess(viewerUser, project.id, false);
await accessService.addGroupToRole(
groupWithProjectAccess.id,
projectRole.id,
'SomeAdminUser',
project,
project.id,
);
await hasCommonProjectAccess(viewerUser, project, true);
await hasCommonProjectAccess(viewerUser, project.id, true);
});
test('Should not lose user role access when given permissions from a group', async () => {
const project = 'yet-another-project';
const project = {
id: 'yet-another-project-lose',
name: 'yet-another-project-lose',
};
const user = editorUser;
const groupStore = stores.groupStore;
await accessService.createDefaultProjectRoles(user, project);
await projectService.createProject(project, user);
// await accessService.createDefaultProjectRoles(user, project.id);
const groupWithNoAccess = await groupStore.create({
name: 'ViewersOnly',
@ -908,7 +919,7 @@ test('Should not lose user role access when given permissions from a group', asy
await groupStore.addNewUsersToGroup(
groupWithNoAccess.id,
[{ user: editorUser, role: 'Owner' }],
[{ user: user }],
'Admin',
);
@ -918,23 +929,33 @@ test('Should not lose user role access when given permissions from a group', asy
groupWithNoAccess.id,
viewerRole.id,
'SomeAdminUser',
project,
project.id,
);
await hasFullProjectAccess(editorUser, project, true);
await hasFullProjectAccess(user, project.id, true);
});
test('Should allow user to take multiple group roles and have expected permissions on each project', async () => {
const projectForCreate =
'project-that-should-have-create-toggle-permission';
const projectForDelete =
'project-that-should-have-delete-toggle-permission';
const projectForCreate = {
id: 'project-that-should-have-create-toggle-permission',
name: 'project-that-should-have-create-toggle-permission',
description: 'Blah',
};
const projectForDelete = {
id: 'project-that-should-have-delete-toggle-permission',
name: 'project-that-should-have-delete-toggle-permission',
description: 'Blah',
};
const groupStore = stores.groupStore;
const viewerUser = await createUserViewerAccess(
'Victor Viewer',
'victore@getunleash.io',
);
await projectService.createProject(projectForCreate, editorUser);
await projectService.createProject(projectForDelete, editorUser);
const groupWithCreateAccess = await groupStore.create({
name: 'ViewersOnly',
description: '',
@ -947,13 +968,13 @@ test('Should allow user to take multiple group roles and have expected permissio
await groupStore.addNewUsersToGroup(
groupWithCreateAccess.id,
[{ user: viewerUser, role: 'Owner' }],
[{ user: viewerUser }],
'Admin',
);
await groupStore.addNewUsersToGroup(
groupWithDeleteAccess.id,
[{ user: viewerUser, role: 'Owner' }],
[{ user: viewerUser }],
'Admin',
);
@ -989,28 +1010,28 @@ test('Should allow user to take multiple group roles and have expected permissio
groupWithCreateAccess.id,
deleteFeatureRole.id,
'SomeAdminUser',
projectForDelete,
projectForDelete.id,
);
await accessService.addGroupToRole(
groupWithDeleteAccess.id,
createFeatureRole.id,
'SomeAdminUser',
projectForCreate,
projectForCreate.id,
);
expect(
await accessService.hasPermission(
viewerUser,
permissions.CREATE_FEATURE,
projectForCreate,
projectForCreate.id,
),
).toBe(true);
expect(
await accessService.hasPermission(
viewerUser,
permissions.DELETE_FEATURE,
projectForCreate,
projectForCreate.id,
),
).toBe(false);
@ -1018,14 +1039,14 @@ test('Should allow user to take multiple group roles and have expected permissio
await accessService.hasPermission(
viewerUser,
permissions.CREATE_FEATURE,
projectForDelete,
projectForDelete.id,
),
).toBe(false);
expect(
await accessService.hasPermission(
viewerUser,
permissions.DELETE_FEATURE,
projectForDelete,
projectForDelete.id,
),
).toBe(true);
});

File diff suppressed because it is too large Load Diff

View File

@ -834,7 +834,11 @@ test('should not update role for user on project when she is the owner', async (
});
test('Should allow bulk update of group permissions', async () => {
const project = 'bulk-update-project';
const project = {
id: 'bulk-update-project',
name: 'bulk-update-project',
};
await projectService.createProject(project, user.id);
const groupStore = stores.groupStore;
const user1 = await stores.userStore.insert({
@ -862,7 +866,7 @@ test('Should allow bulk update of group permissions', async () => {
});
await projectService.addAccess(
project,
project.id,
createFeatureRole.id,
{
users: [{ id: user1.id }],
@ -906,9 +910,14 @@ test('Should bulk update of only users', async () => {
});
test('Should allow bulk update of only groups', async () => {
const project = 'bulk-update-project';
const project = {
id: 'bulk-update-project-only',
name: 'bulk-update-project-only',
};
const groupStore = stores.groupStore;
await projectService.createProject(project, user.id);
const group1 = await groupStore.create({
name: 'ViewersOnly',
description: '',
@ -929,7 +938,7 @@ test('Should allow bulk update of only groups', async () => {
});
await projectService.addAccess(
project,
project.id,
createFeatureRole.id,
{
users: [],

View File

@ -32,9 +32,9 @@ test('Can create new setting', async () => {
expect(actual).toStrictEqual(someData);
const { eventStore } = stores;
const createdEvents = await eventStore.getEventsFilterByType(
SETTING_CREATED,
);
const createdEvents = await eventStore.searchEvents({
type: SETTING_CREATED,
});
expect(createdEvents).toHaveLength(1);
});
@ -46,9 +46,9 @@ test('Can delete setting', async () => {
const actual = await service.get('some-setting');
expect(actual).toBeUndefined();
const { eventStore } = stores;
const createdEvents = await eventStore.getEventsFilterByType(
SETTING_DELETED,
);
const createdEvents = await eventStore.searchEvents({
type: SETTING_DELETED,
});
expect(createdEvents).toHaveLength(1);
});
@ -61,8 +61,8 @@ test('Can update setting', async () => {
{ ...someData, test: 'fun' },
'test-user',
);
const updatedEvents = await eventStore.getEventsFilterByType(
SETTING_UPDATED,
);
const updatedEvents = await eventStore.searchEvents({
type: SETTING_UPDATED,
});
expect(updatedEvents).toHaveLength(1);
});

View File

@ -209,12 +209,12 @@ test('Should get all events of type', async () => {
return eventStore.store(event);
}),
);
const featureCreatedEvents = await eventStore.getEventsFilterByType(
FEATURE_CREATED,
);
const featureCreatedEvents = await eventStore.searchEvents({
type: FEATURE_CREATED,
});
expect(featureCreatedEvents).toHaveLength(3);
const featureDeletedEvents = await eventStore.getEventsFilterByType(
FEATURE_DELETED,
);
const featureDeletedEvents = await eventStore.searchEvents({
type: FEATURE_DELETED,
});
expect(featureDeletedEvents).toHaveLength(3);
});

View File

@ -11,10 +11,6 @@ class FakeEventStore extends EventEmitter implements IEventStore {
this.events = [];
}
async getEventsForFeature(featureName: string): Promise<IEvent[]> {
return this.events.filter((e) => e.featureName === featureName);
}
store(event: IEvent): Promise<void> {
this.events.push(event);
this.emit(event.type, event);
@ -58,12 +54,8 @@ class FakeEventStore extends EventEmitter implements IEventStore {
return this.events;
}
async getEventsFilterByType(type: string): Promise<IEvent[]> {
return this.events.filter((e) => e.type === type);
}
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
return this.events.filter((e) => e.project === project);
async searchEvents(): Promise<IEvent[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -50,13 +50,6 @@ export default class FakeGroupStore implements IGroupStore {
throw new Error('Method not implemented.');
}
updateExistingUsersInGroup(
id: number,
users: IGroupUserModel[],
): Promise<void> {
throw new Error('Method not implemented.');
}
getAllUsersByGroups(groupIds: number[]): Promise<IGroupUser[]> {
throw new Error('Method not implemented.');
}

View File

@ -2,8 +2,10 @@
title: Impression data
---
:::info Availability
The impression data feature was introduced in **Unleash 4.7**. Listening for events requires [an SDK that supports impression data events](../sdks/index.md#server-side-sdk-compatibility-table). Currently, it's only supported in the [Unleash Proxy client](../sdks/proxy-javascript.md) and [React Proxy client](../sdks/proxy-react.md).
:::info
Availability The impression data feature was introduced in **Unleash 4.7**. It is available in the JavaScript-based proxy clients and in some server-side SDKs. Please refer to the [SDK compatibility table](../sdks/index.md#server-side-sdk-compatibility-table) for an overview of server-side SDKs that support it.
:::
Unleash can provide you with **impression data** about the toggles in your application. Impression data contains information about a specific feature toggle activation check: The client SDK will emit an **impression event** whenever it calls `isEnabled` or `getVariant`.
@ -23,16 +25,16 @@ The `getVariant` event contains all the information found in an `isEnabled` even
This table describes all the properties on the impression events:
| Property name | Description | Event type |
|---------------|--------------------------------------------------------------------------------------|--------------------------|
| `eventType` | The type of the event: `isEnabled` or `getVariant` | All |
| `eventId` | A globally unique id (GUID) assigned to this event. | All |
| `context` | A representation of the current [Unleash Context](../user_guide/unleash-context.md). | All |
| `enabled` | Whether the toggle was enabled or not at when the client made the request. | All |
| `featureName` | The name of the feature toggle. | All |
| `variant` | The name of the active variant | `getVariant` events only |
| Property name | Description | Event type |
| --- | --- | --- |
| `eventType` | The type of the event: `isEnabled` or `getVariant` | All |
| `eventId` | A globally unique id (GUID) assigned to this event. | All |
| `context` | A representation of the current [Unleash Context](../user_guide/unleash-context.md). | All |
| `enabled` | Whether the toggle was enabled or not at when the client made the request. | All |
| `featureName` | The name of the feature toggle. | All |
| `variant` | The name of the active variant | `getVariant` events only |
### Example `isEnabled` event {#example-isenabled}
### Example `isEnabled` event {#example-isenabled}
```js
{
@ -48,10 +50,8 @@ This table describes all the properties on the impression events:
}
```
### Example `getVariant` event {#example-getvariant}
```js
{
eventType: 'getVariant',
@ -69,8 +69,7 @@ This table describes all the properties on the impression events:
## Enabling impression data
Impression data is strictly an **opt-in** feature and must be enabled on a **per-toggle basis**.
You can enable and disable it both when you create a toggle and when you edit a toggle.
Impression data is strictly an **opt-in** feature and must be enabled on a **per-toggle basis**. You can enable and disable it both when you create a toggle and when you edit a toggle.
You can enable impression data via the impression data toggle in the admin UI's toggle creation form. You can also go via the [the API, using the `impressionData` option](../api/admin/feature-toggles-api-v2.md#create-toggle). For more detailed instructions, see [the section on enabling impression data in the how-to guide for capturing impression data](../how-to/how-to-capture-impression-data.mdx#step-1).
@ -78,10 +77,6 @@ You can enable impression data via the impression data toggle in the admin UI's
## Example setup
:::caution
This functionality is currently only supported in the [Unleash Proxy client](../sdks/proxy-javascript.md) and [React Proxy client](../sdks/proxy-react.md).
:::
The exact setup will vary depending on your [client SDK](../sdks/index.md). The below example configures the [Unleash Proxy client](/sdks/proxy-javascript) to listen for impression events and log them to the console. If "my-feature-toggle" is configured to emit impression data, then it will trigger an impression event as soon as Unleash is ready.
```js
@ -93,12 +88,12 @@ const unleash = new UnleashClient({
unleash.start();
unleash.on("ready", () => {
unleash.isEnabled("my-feature-toggle");
})
unleash.on('ready', () => {
unleash.isEnabled('my-feature-toggle');
});
unleash.on("impression", (event) => {
unleash.on('impression', (event) => {
// Capture the event here and pass it internal data lake or analytics provider
console.log(event);
})
});
```

View File

@ -5,7 +5,11 @@ title: /api/admin/events
import ApiRequest from '@site/src/components/ApiRequest'
:::note In order to access the admin API endpoints you need to identify yourself. Unless you're using the `none` authentication method, you'll need to [create an ADMIN token](/user_guide/api-token) and add an Authorization header using the token. :::
:::note
In order to access the admin API endpoints you need to identify yourself. Unless you're using the `none` authentication method, you'll need to [create an ADMIN token](/user_guide/api-token) and add an Authorization header using the token.
:::
The Events API lets you retrieve events from your Unleash instance.
@ -191,7 +195,11 @@ This event fires when you create a feature. The `data` property contains the det
### `feature-updated`
:::caution Deprecation notice This event type was replaced by more granular event types in Unleash 4.3. From Unleash 4.3 onwards, you'll need to use the events listed later in this section instead. :::
:::caution Deprecation notice
This event type was replaced by more granular event types in Unleash 4.3. From Unleash 4.3 onwards, you'll need to use the events listed later in this section instead.
:::
This event fires when a feature gets updated in some way. The `data` property contains the new state of the toggle. This is a legacy event, so it does not populate `preData` property.

View File

@ -50,13 +50,13 @@ This guide takes you through how to use user groups to manage permissions on you
![The groups page shown with the add user button highlighted.](/img/add-user-to-group-step-1.png)
6. Find the user you'd like to add to the group add them.
6. Find the user you'd like to add to the group and select them.
![The groups page shown with a user selected.](/img/add-user-to-group-step-2.png)
7. Assign the user a role in the group and save the group. Remember that every group needs to have _at least_ one owner.
7. Review the group users and save when you're happy.
![The groups page shown with the user role highlighted.](/img/add-user-to-group-step-3.png)
![The edit groups page shown with the save button highlighted.](/img/add-user-to-group-step-3.png)
## Assigning groups to projects

View File

@ -10,13 +10,12 @@ In this example we want to define an activation strategy offers a scheduled rele
1. **Navigate to the strategies view**. Interact with the "Configure" button in the page header and then go to the "Strategies" link in the dropdown menu that appears.
![A visual guide for how to navigate to the strategies page in the Unleash admin UI. It shows the steps described in the preceding paragraph.](/img/custom-strategy-navigation.png)
![A visual guide for how to navigate to the strategies page in the Unleash admin UI. It shows the steps described in the preceding paragraph.](/img/custom-strategy-navigation.png)
2. **Define your strategy**. Use the "Add new strategy" button to open the strategy creation form. Fill in the form to define your strategy. Refer to [the custom strategy reference documentation](../advanced/custom-activation-strategy.md#definition) for a full list of options.
![A strategy creation form. It has fields labeled "strategy name" — "TimeStamp" — and "description" — "activate toggle after a given timestamp". It also has fields for a parameter named "enableAfter". The parameter is of type "string" and the parameter description is "Expected format: YYYY-MM-DD HH:MM". The parameter is required.](/img/timestamp_create_strategy.png)
## Step 2: Apply your custom strategy to a feature toggle {#step-2}
**Navigate to your feature toggle** and **apply the strategy** you just created.
@ -27,8 +26,8 @@ In this example we want to define an activation strategy offers a scheduled rele
The steps to implement a custom strategy for your client depend on the kind of client SDK you're using:
- if you're using a server-side client SDK, follow the steps in [option A](#step-3-a "Step 3 option A: implement the strategy for a server-side client SDK").
- if you're using a front-end client SDK ([Android](../sdks/android-proxy.md), [JavaScript](../sdks/proxy-javascript.md), [React](../sdks/proxy-react.md), [iOS](../sdks/proxy-ios.md)), follow the steps in [option B](#step-3-b "Step 3 option B: implementing the strategy for a front-end client SDK")
- if you're using a server-side client SDK, follow the steps in [option A](#step-3-a 'Step 3 option A: implement the strategy for a server-side client SDK').
- if you're using a front-end client SDK ([Android](../sdks/android-proxy.md), [JavaScript](../sdks/proxy-javascript.md), [React](../sdks/proxy-react.md), [iOS](../sdks/proxy-ios.md)), follow the steps in [option B](#step-3-b 'Step 3 option B: implementing the strategy for a front-end client SDK')
### Option A: Implement the strategy for a server-side client SDK {#step-3-a}
@ -48,7 +47,7 @@ The steps to implement a custom strategy for your client depend on the kind of c
}
```
2. **Register the custom strategy with the Unleash Client**. When instantiating the Unleash Client, provide it with a list of the custom strategies you'd like to use — again: refer to _your_ client SDK's docs for the specifics.
2. **Register the custom strategy with the Unleash Client**. When instantiating the Unleash Client, provide it with a list of the custom strategies you'd like to use — again: refer to _your_ client SDK's docs for the specifics.
Here's a full, working example for Node.js. Notice the `strategies` property being passed to the `initialize` function.
@ -78,12 +77,12 @@ The steps to implement a custom strategy for your client depend on the kind of c
console.log(isEnabled('demo.TimeStampRollout'));
}, 1000);
});
```
### Option B: Implement the strategy for a front-end client SDK {#step-3-b}
Front-end client SDKs don't evaluate strategies directly, so you need to implement the **custom strategy in the [Unleash Proxy](../sdks/unleash-proxy.md)**. Depending on how you run the Unleash Proxy, follow one of the below series of steps:
- If you're running the Unleash Proxy as a Docker container, refer to the [steps for using a containerized Proxy](#step-3-b-docker).
- If you're using the Unleash Proxy via Node.js, refer to the [steps for using custom strategies via Node.js](#step-3-b-node).
@ -94,14 +93,14 @@ Strategies are stored in separate JavaScript files and loaded into the container
1. **Create a strategies directory.** Create a directory that Docker has access to where you can store your strategies. The next steps assume you called it `strategies`
2. **Initialize a Node.js project** and **install the Unleash Client**:
``` shell npm2yarn
```shell npm2yarn
npm init -y && \
npm install unleash-client
```
3. **Create a strategy file** and **implement your strategies**. Remember to **export your list of strategies**. The next steps will assume you called the file `timestamp.js`. An example implementation looks like this:
3. **Create a strategy file** and **implement your strategies**. Remember to **export your list of strategies**. The next steps will assume you called the file `timestamp.js`. An example implementation looks like this:
``` js
```js
const { Strategy } = require('unleash-client');
class TimeStampStrategy extends Strategy {
@ -119,9 +118,9 @@ Strategies are stored in separate JavaScript files and loaded into the container
4. **Mount the strategies directory** and **point the [Unleash Proxy docker container](https://hub.docker.com/r/unleashorg/unleash-proxy) at your strategies file**. The highlighted lines below show the extra options you need to add. The following command assumes that your strategies directory is a direct subdirectory of your current working directory. Modify the rest of the command to suit your needs.
``` shell
```shell
docker run --name unleash-proxy --pull=always \
-e UNLEASH_PROXY_SECRETS=some-secret \
-e UNLEASH_PROXY_CLIENT_KEYS=some-secret \
-e UNLEASH_URL='http://unleash:4242/api/' \
-e UNLEASH_API_TOKEN=${API_TOKEN} \
# highlight-start
@ -137,13 +136,13 @@ The Unleash Proxy accepts a `customStrategies` property as part of its initializ
1. **Install the `unleash-client` package**. You'll need this to implement the custom strategy:
``` shell npm2yarn
```shell npm2yarn
npm install unleash-client
```
2. **Implement your strategy**. You can import it from a different file or put it in the same file as the Proxy initialization. For instance, a `TimeStampStrategy` could look like this:
``` js
```js
const { Strategy } = require('unleash-client');
class TimeStampStrategy extends Strategy {
@ -159,7 +158,7 @@ The Unleash Proxy accepts a `customStrategies` property as part of its initializ
3. **Pass the strategy to the Proxy Client** using the **`customStrategies`** option. A full code example:
``` javascript
```javascript
const { createApp } = require('@unleash/proxy');
const { Strategy } = require('unleash-client');
@ -176,16 +175,17 @@ The Unleash Proxy accepts a `customStrategies` property as part of its initializ
const port = 3000;
const app = createApp({
unleashUrl: 'https://app.unleash-hosted.com/demo/api/',
unleashApiToken: '*:default.56907a2fa53c1d16101d509a10b78e36190b0f918d9f122d',
proxySecrets: ['proxy-secret', 'another-proxy-secret', 's1'],
refreshInterval: 1000,
// highlight-next-line
customStrategies: [new TimeStampStrategy()]
unleashUrl: 'https://app.unleash-hosted.com/demo/api/',
unleashApiToken:
'*:default.56907a2fa53c1d16101d509a10b78e36190b0f918d9f122d',
clientKeys: ['proxy-secret', 'another-proxy-secret', 's1'],
refreshInterval: 1000,
// highlight-next-line
customStrategies: [new TimeStampStrategy()],
});
app.listen(port, () =>
// eslint-disable-next-line no-console
console.log(`Unleash Proxy listening on http://localhost:${port}/proxy`),
// eslint-disable-next-line no-console
console.log(`Unleash Proxy listening on http://localhost:${port}/proxy`),
);
```

View File

@ -60,8 +60,8 @@ If you see an item marked with a ❌ that you would find useful, feel free to re
| Default metrics interval | 60s | 60s | 60s | 60s | 60s | 60s | 30s | 15s | 30s |
| Context provider | ✅ | N/A | N/A | N/A | N/A | ✅ | ✅ | N/A | N/A |
| Global fallback function | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | N/A |
| Toggle Query: `namePrefix` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | ❌ | ✅ |
| Toggle Query: `tags` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | ❌ | ✅ |
| Toggle Query: `namePrefix` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | ❌ | ✅ |
| Toggle Query: `tags` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | ❌ | ✅ |
| Toggle Query: `project_name` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | N/A | ⭕ | ✅ |
| **Category: Custom Headers** | | | | | | | | | |
| static | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⭕ | N/A |
@ -72,7 +72,7 @@ If you see an item marked with a ❌ that you would find useful, feel free to re
| [Gradual rollout: custom stickiness](../user_guide/activation_strategy#customize-stickiness-beta) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⭕ | ✅ |
| [UserID](../user_guide/activation_strategy#userids) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [IP](../user_guide/activation_strategy#ips) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [IP](../user_guide/activation_strategy#ips): CIDR syntax | ✅ | ✅ | ✅ | ✅ | ✅ | ⭕ | | ✅ | ✅ |
| [IP](../user_guide/activation_strategy#ips): CIDR syntax | ✅ | ✅ | ✅ | ✅ | ✅ | ⭕ | | ✅ | ✅ |
| [Hostname](../user_guide/activation_strategy#hostnames) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Category: [Custom strategies](../advanced/custom_activation_strategy)** | | | | | | | | | |
| Basic support | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@ -98,7 +98,7 @@ If you see an item marked with a ❌ that you would find useful, feel free to re
| Can disable metrics | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Client registration | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic usage metrics (yes/no) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Impression data](../advanced/impression-data.md) | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | | ⭕ | N/A |
| [Impression data](../advanced/impression-data.md) | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | | ⭕ | N/A |
| **Category: Bootstrap (beta)** | | | | | | | | | |
| Bootstrap from file | ✅ | ✅ | ✅ | ⭕ | ✅ | ⭕ | ✅ | ⭕ | ✅ |
| Custom Bootstrap implementation | ✅ | ✅ | ✅ | ⭕ | ✅ | ⭕ | ✅ | ⭕ | ✅ |

View File

@ -282,7 +282,7 @@ Follow steps outlined in the [Run Unleash with Docker](#run-unleash-with-docker)
```sh
docker run \
-e UNLEASH_PROXY_SECRETS=some-secret \
-e UNLEASH_PROXY_CLIENT_KEYS=some-secret \
-e UNLEASH_URL='http://unleash:4242/api/' \
-e UNLEASH_API_TOKEN='${API_KEY}' \
-p 3000:3000 \

View File

@ -66,7 +66,7 @@ A user group consists of the following:
- a **name** (required)
- a **description** (optional)
- one or more users. At least one user must have the owner role
- a list of users (optional)
Groups do nothing on their own. They must be given a role on a project to assign permissions.

Some files were not shown because too many files have changed in this diff Show More