diff --git a/package.json b/package.json index fea3e3180d..22ebde63c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/db/event-store.test.ts b/src/lib/db/event-store.test.ts index 849703b49d..72891ea9b9 100644 --- a/src/lib/db/event-store.test.ts +++ b/src/lib/db/event-store.test.ts @@ -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); }); diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 4980e19495..013aacd34e 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -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 { - 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 { + let query = this.db + .select(EVENT_COLUMNS) + .from(TABLE) + .limit(search.limit ?? 100) + .offset(search.offset ?? 0) + .orderBy('created_at', 'desc'); - async getEventsFilterByProject(project: string): Promise { - 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 { 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 []; } diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index bd5331c2f6..a3b8e89ef7 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -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', diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index 9c6e27ba99..f665812559 100644 --- a/src/lib/db/feature-toggle-client-store.ts +++ b/src/lib/db/feature-toggle-client-store.ts @@ -57,6 +57,7 @@ export default class FeatureToggleClientStore featureQuery?: IFeatureToggleQuery, archived: boolean = false, isAdmin: boolean = true, + includeStrategyIds?: boolean, ): Promise { 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 { - return this.getAll(featureQuery, false, false); + return this.getAll(featureQuery, false, false, includeStrategyIds); } async getAdmin( diff --git a/src/lib/db/group-store.ts b/src/lib/db/group-store.ts index 5a1d39f688..7e33f480e7 100644 --- a/src/lib/db/group-store.ts +++ b/src/lib/db/group-store.ts @@ -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 { 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 { - 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 { await this.db.transaction(async (tx) => { await this.addNewUsersToGroup(groupId, newUsers, userName, tx); - await this.updateExistingUsersInGroup(groupId, existingUsers, tx); await this.deleteOldUsersFromGroup(deletableUsers, tx); }); } diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index f643905f58..b97b917e22 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -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) { diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 62e1252bbb..b33801641f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap index 76dc708e3f..50dd2a1b4d 100644 --- a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap @@ -17,7 +17,7 @@ Object { } `; -exports[`featureSchema overrides 1`] = ` +exports[`featureSchema variant override values must be an array 1`] = ` Object { "errors": Array [ Object { diff --git a/src/lib/openapi/spec/bootstrap-ui-schema.test.ts b/src/lib/openapi/spec/bootstrap-ui-schema.test.ts deleted file mode 100644 index e49209b12d..0000000000 --- a/src/lib/openapi/spec/bootstrap-ui-schema.test.ts +++ /dev/null @@ -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(); -}); diff --git a/src/lib/openapi/spec/bootstrap-ui-schema.ts b/src/lib/openapi/spec/bootstrap-ui-schema.ts deleted file mode 100644 index b810f0032a..0000000000 --- a/src/lib/openapi/spec/bootstrap-ui-schema.ts +++ /dev/null @@ -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; diff --git a/src/lib/openapi/spec/client-feature-schema.ts b/src/lib/openapi/spec/client-feature-schema.ts index 3b7fe12c28..966d8ff2f8 100644 --- a/src/lib/openapi/spec/client-feature-schema.ts +++ b/src/lib/openapi/spec/client-feature-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/client-features-schema.test.ts b/src/lib/openapi/spec/client-features-schema.test.ts index 620b976ecf..20bf229691 100644 --- a/src/lib/openapi/spec/client-features-schema.test.ts +++ b/src/lib/openapi/spec/client-features-schema.test.ts @@ -22,6 +22,16 @@ test('clientFeaturesSchema required fields', () => { weight: 1, weightType: 'b', stickiness: 'c', + payload: { + type: 'a', + value: 'b', + }, + overrides: [ + { + contextName: 'a', + values: ['b'], + }, + ], }, ], }, diff --git a/src/lib/openapi/spec/client-features-schema.ts b/src/lib/openapi/spec/client-features-schema.ts index e1d62617d2..29d935e64a 100644 --- a/src/lib/openapi/spec/client-features-schema.ts +++ b/src/lib/openapi/spec/client-features-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/client-variant-schema.ts b/src/lib/openapi/spec/client-variant-schema.ts deleted file mode 100644 index 432575e04c..0000000000 --- a/src/lib/openapi/spec/client-variant-schema.ts +++ /dev/null @@ -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; diff --git a/src/lib/openapi/spec/constraint-schema.ts b/src/lib/openapi/spec/constraint-schema.ts index eb08a2cdc4..bb1e97d19e 100644 --- a/src/lib/openapi/spec/constraint-schema.ts +++ b/src/lib/openapi/spec/constraint-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/context-field-schema.test.ts b/src/lib/openapi/spec/context-field-schema.test.ts index 73a19ee3e9..7e1b8788dc 100644 --- a/src/lib/openapi/spec/context-field-schema.test.ts +++ b/src/lib/openapi/spec/context-field-schema.test.ts @@ -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( diff --git a/src/lib/openapi/spec/feature-events-schema.ts b/src/lib/openapi/spec/feature-events-schema.ts index dca62d7e6e..912fd52fd7 100644 --- a/src/lib/openapi/spec/feature-events-schema.ts +++ b/src/lib/openapi/spec/feature-events-schema.ts @@ -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: { diff --git a/src/lib/openapi/spec/feature-schema.test.ts b/src/lib/openapi/spec/feature-schema.test.ts index 61f2bc2a8e..706051ac2a 100644 --- a/src/lib/openapi/spec/feature-schema.test.ts +++ b/src/lib/openapi/spec/feature-schema.test.ts @@ -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: [ diff --git a/src/lib/openapi/spec/group-user-model-schema.ts b/src/lib/openapi/spec/group-user-model-schema.ts index 2992e89624..6f287ac73e 100644 --- a/src/lib/openapi/spec/group-user-model-schema.ts +++ b/src/lib/openapi/spec/group-user-model-schema.ts @@ -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', }, diff --git a/src/lib/openapi/spec/groups-schema.test.ts b/src/lib/openapi/spec/groups-schema.test.ts index 58987c6be3..64b2b0036b 100644 --- a/src/lib/openapi/spec/groups-schema.test.ts +++ b/src/lib/openapi/spec/groups-schema.test.ts @@ -9,7 +9,6 @@ test('groupsSchema', () => { name: 'Group', users: [ { - role: 'Owner', user: { id: 3, }, diff --git a/src/lib/openapi/spec/playground-constraint-schema.ts b/src/lib/openapi/spec/playground-constraint-schema.ts new file mode 100644 index 0000000000..e26cbc065a --- /dev/null +++ b/src/lib/openapi/spec/playground-constraint-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/playground-feature-schema.test.ts b/src/lib/openapi/spec/playground-feature-schema.test.ts index 1e364ff788..e4cf049c3b 100644 --- a/src/lib/openapi/spec/playground-feature-schema.test.ts +++ b/src/lib/openapi/spec/playground-feature-schema.test.ts @@ -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 => + fc + .tuple(fc.boolean(), strategyConstraint()) + .map(([result, constraint]) => ({ + ...constraint, + result, + })); + +const playgroundStrategyConstraints = (): Arbitrary< + PlaygroundConstraintSchema[] +> => fc.array(playgroundStrategyConstraint()); + +const playgroundSegment = (): Arbitrary => + fc.record({ + name: fc.string({ minLength: 1 }), + id: fc.nat(), + result: fc.boolean(), + constraints: playgroundStrategyConstraints(), + }); + +const playgroundStrategy = ( + name: string, + parameters: Arbitrary>, +): Arbitrary => + 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 => + 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 => 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 => : undefined; activeVariant = { - enabled: isEnabled, + enabled: true, name: targetVariant.name, payload: targetPayload, }; @@ -51,6 +178,10 @@ export const generate = (): Arbitrary => return { ...feature, isEnabled, + strategies: { + result: strategyResult(), + data: feature.strategies, + }, variants: generatedVariants, variant: activeVariant, }; diff --git a/src/lib/openapi/spec/playground-feature-schema.ts b/src/lib/openapi/spec/playground-feature-schema.ts index f658534670..0c18be5891 100644 --- a/src/lib/openapi/spec/playground-feature-schema.ts +++ b/src/lib/openapi/spec/playground-feature-schema.ts @@ -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< diff --git a/src/lib/openapi/spec/playground-request-schema.ts b/src/lib/openapi/spec/playground-request-schema.ts index 4e61b74ea9..9ac53c9d94 100644 --- a/src/lib/openapi/spec/playground-request-schema.ts +++ b/src/lib/openapi/spec/playground-request-schema.ts @@ -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, }, }, diff --git a/src/lib/openapi/spec/playground-response-schema.ts b/src/lib/openapi/spec/playground-response-schema.ts index 8c676d0e21..71359bd05b 100644 --- a/src/lib/openapi/spec/playground-response-schema.ts +++ b/src/lib/openapi/spec/playground-response-schema.ts @@ -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, diff --git a/src/lib/openapi/spec/playground-segment-schema.ts b/src/lib/openapi/spec/playground-segment-schema.ts new file mode 100644 index 0000000000..5338a1d49c --- /dev/null +++ b/src/lib/openapi/spec/playground-segment-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/playground-strategy-schema.ts b/src/lib/openapi/spec/playground-strategy-schema.ts new file mode 100644 index 0000000000..09d376f6a8 --- /dev/null +++ b/src/lib/openapi/spec/playground-strategy-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/project-schema.test.ts b/src/lib/openapi/spec/project-schema.test.ts new file mode 100644 index 0000000000..9909014019 --- /dev/null +++ b/src/lib/openapi/spec/project-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/search-events-schema.ts b/src/lib/openapi/spec/search-events-schema.ts new file mode 100644 index 0000000000..f1d187a343 --- /dev/null +++ b/src/lib/openapi/spec/search-events-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/tag-types-schema.test.ts b/src/lib/openapi/spec/tag-types-schema.test.ts new file mode 100644 index 0000000000..2765d8439b --- /dev/null +++ b/src/lib/openapi/spec/tag-types-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/ui-config-schema.ts b/src/lib/openapi/spec/ui-config-schema.ts index 439b81630d..c363dc6633 100644 --- a/src/lib/openapi/spec/ui-config-schema.ts +++ b/src/lib/openapi/spec/ui-config-schema.ts @@ -28,6 +28,9 @@ export const uiConfigSchema = { disablePasswordAuth: { type: 'boolean', }, + emailEnabled: { + type: 'boolean', + }, segmentValuesLimit: { type: 'number', }, diff --git a/src/lib/openapi/spec/user-schema.test.ts b/src/lib/openapi/spec/user-schema.test.ts new file mode 100644 index 0000000000..9bc1008297 --- /dev/null +++ b/src/lib/openapi/spec/user-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/variant-schema.ts b/src/lib/openapi/spec/variant-schema.ts index 65e6880033..8418dc0f8e 100644 --- a/src/lib/openapi/spec/variant-schema.ts +++ b/src/lib/openapi/spec/variant-schema.ts @@ -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', diff --git a/src/lib/openapi/util/standard-responses.ts b/src/lib/openapi/util/standard-responses.ts index c206249215..26082e8e0c 100644 --- a/src/lib/openapi/util/standard-responses.ts +++ b/src/lib/openapi/util/standard-responses.ts @@ -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 => statusCodes.reduce( - (acc, n) => ({ + (acc, statusCode) => ({ ...acc, - [n]: standardResponses[n], + [statusCode]: standardResponses[statusCode], }), {} as Partial, ); diff --git a/src/lib/routes/admin-api/bootstrap-ui.test.ts b/src/lib/routes/admin-api/bootstrap-ui.test.ts deleted file mode 100644 index 3a286d2d34..0000000000 --- a/src/lib/routes/admin-api/bootstrap-ui.test.ts +++ /dev/null @@ -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); -}); diff --git a/src/lib/routes/admin-api/bootstrap-ui.ts b/src/lib/routes/admin-api/bootstrap-ui.ts deleted file mode 100644 index 8bd11285dc..0000000000 --- a/src/lib/routes/admin-api/bootstrap-ui.ts +++ /dev/null @@ -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, - ): Promise { - const jobs: [ - Promise, - Promise, - Promise, - Promise, - Promise, - Promise, - ] = [ - 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; diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts index db3d6c56a0..9e10fa6d3e 100644 --- a/src/lib/routes/admin-api/config.ts +++ b/src/lib/routes/admin-api/config.ts @@ -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, diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 4537145fe8..40805fc504 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -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, ): Promise { - 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, + res: Response, + ): Promise { + const events = await this.eventService.searchEvents(req.body); + + const response = { + version, + events: serializeDates(this.maybeAnonymiseEvents(events)), }; this.openApiService.respondWithValidation( diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 37d5419f18..2a140a8032 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -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, diff --git a/src/lib/routes/admin-api/playground.ts b/src/lib/routes/admin-api/playground.ts index e614a08d4b..46c349ce80 100644 --- a/src/lib/routes/admin-api/playground.ts +++ b/src/lib/routes/admin-api/playground.ts @@ -55,7 +55,7 @@ export default class PlaygroundController extends Controller { req: Request, res: Response, ): Promise { - const response: PlaygroundResponseSchema = { + const response = { input: req.body, features: await this.playgroundService.evaluateQuery( req.body.projects, diff --git a/src/lib/services/event-service.ts b/src/lib/services/event-service.ts index e31456f420..0f878d3557 100644 --- a/src/lib/services/event-service.ts +++ b/src/lib/services/event-service.ts @@ -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 { - return this.eventStore.getEventsForFeature(name); - } - - async getEventsForProject(project: string): Promise { - return this.eventStore.getEventsFilterByProject(project); + async searchEvents(search: SearchEventsSchema): Promise { + return this.eventStore.searchEvents(search); } } diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index b04f52ea3e..f6be89c954 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -533,8 +533,9 @@ class FeatureToggleService { async getClientFeatures( query?: IFeatureToggleQuery, + includeIds?: boolean, ): Promise { - return this.featureToggleClientStore.getClient(query); + return this.featureToggleClientStore.getClient(query, includeIds); } /** diff --git a/src/lib/services/group-service.ts b/src/lib/services/group-service.ts index 7cfb81d221..6ec7514dbd 100644 --- a/src/lib/services/group-service.ts +++ b/src/lib/services/group-service.ts @@ -176,7 +176,7 @@ export class GroupService { } async validateGroup( - { name, users }: IGroupModel, + { name }: IGroupModel, existingGroup?: IGroup, ): Promise { 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 { @@ -215,7 +211,6 @@ export class GroupService { return { user: user, joinedAt: roleUser.joinedAt, - role: roleUser.role, }; }); return { ...group, users: finalUsers }; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 841756ef23..b6394e49b9 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -89,6 +89,7 @@ export const createServices = ( const clientSpecService = new ClientSpecService(config); const playgroundService = new PlaygroundService(config, { featureToggleServiceV2, + segmentService, }); return { diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index ee99a0ec8e..f8f3104eed 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -59,7 +59,7 @@ export class OpenApiService { validation: err.validationErrors, }); } else { - next(); + next(err); } }); } diff --git a/src/lib/services/playground-service.ts b/src/lib/services/playground-service.ts index 0359794e0b..86bee59389 100644 --- a/src/lib/services/playground-service.ts +++ b/src/lib/services/playground-service.ts @@ -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, + segmentService, + }: Pick, ) { 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 { - 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; diff --git a/src/lib/types/group.ts b/src/lib/types/group.ts index 811a07e8b9..f107c9235e 100644 --- a/src/lib/types/group.ts +++ b/src/lib/types/group.ts @@ -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; } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 365cf89ae8..67b2e33aab 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -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, EventEmitter { store(event: IBaseEvent): Promise; batchStore(events: IBaseEvent[]): Promise; getEvents(): Promise; - getEventsFilterByType(name: string): Promise; - getEventsForFeature(featureName: string): Promise; - getEventsFilterByProject(project: string): Promise; + searchEvents(search: SearchEventsSchema): Promise; } diff --git a/src/lib/types/stores/feature-toggle-client-store.ts b/src/lib/types/stores/feature-toggle-client-store.ts index 7b74689495..3e9c65da92 100644 --- a/src/lib/types/stores/feature-toggle-client-store.ts +++ b/src/lib/types/stores/feature-toggle-client-store.ts @@ -3,6 +3,7 @@ import { IFeatureToggleClient, IFeatureToggleQuery } from '../model'; export interface IFeatureToggleClientStore { getClient( featureQuery: Partial, + includeStrategyIds?: boolean, ): Promise; // @Deprecated diff --git a/src/lib/types/stores/group-store.ts b/src/lib/types/stores/group-store.ts index 7569a20203..f4e9ec1664 100644 --- a/src/lib/types/stores/group-store.ts +++ b/src/lib/types/stores/group-store.ts @@ -40,11 +40,6 @@ export interface IGroupStore extends Store { userName: string, ): Promise; - updateExistingUsersInGroup( - groupId: number, - users: IGroupUserModel[], - ): Promise; - existsWithName(name: string): Promise; create(group: IStoreGroup): Promise; diff --git a/src/lib/util/feature-evaluator/client.ts b/src/lib/util/feature-evaluator/client.ts new file mode 100644 index 0000000000..71210cd81d --- /dev/null +++ b/src/lib/util/feature-evaluator/client.ts @@ -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, + }; + } +} diff --git a/src/lib/util/feature-evaluator/constraint.ts b/src/lib/util/feature-evaluator/constraint.ts new file mode 100644 index 0000000000..72c7c3976b --- /dev/null +++ b/src/lib/util/feature-evaluator/constraint.ts @@ -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(); +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); diff --git a/src/lib/util/feature-evaluator/context.ts b/src/lib/util/feature-evaluator/context.ts new file mode 100644 index 0000000000..eaff76211e --- /dev/null +++ b/src/lib/util/feature-evaluator/context.ts @@ -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; +} diff --git a/src/lib/util/feature-evaluator/feature-evaluator.ts b/src/lib/util/feature-evaluator/feature-evaluator.ts new file mode 100644 index 0000000000..0299e03181 --- /dev/null +++ b/src/lib/util/feature-evaluator/feature-evaluator.ts @@ -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; +} + +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 { + 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(); + } +} diff --git a/src/lib/util/feature-evaluator/feature.ts b/src/lib/util/feature-evaluator/feature.ts new file mode 100644 index 0000000000..90e0861be4 --- /dev/null +++ b/src/lib/util/feature-evaluator/feature.ts @@ -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[]; +} diff --git a/src/lib/util/feature-evaluator/helpers.ts b/src/lib/util/feature-evaluator/helpers.ts new file mode 100644 index 0000000000..68d4931f03 --- /dev/null +++ b/src/lib/util/feature-evaluator/helpers.ts @@ -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, '_'); +} diff --git a/src/lib/util/feature-evaluator/index.ts b/src/lib/util/feature-evaluator/index.ts new file mode 100644 index 0000000000..32560d0fb9 --- /dev/null +++ b/src/lib/util/feature-evaluator/index.ts @@ -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 }; diff --git a/src/lib/util/feature-evaluator/repository/bootstrap-provider.ts b/src/lib/util/feature-evaluator/repository/bootstrap-provider.ts new file mode 100644 index 0000000000..7179c91bae --- /dev/null +++ b/src/lib/util/feature-evaluator/repository/bootstrap-provider.ts @@ -0,0 +1,39 @@ +import { ClientFeaturesResponse, FeatureInterface } from '../feature'; +import { Segment } from '../strategy/strategy'; + +export interface BootstrapProvider { + readBootstrap(): Promise; +} + +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 { + if (this.data) { + return { + version: 2, + segments: this.segments, + features: [...this.data], + }; + } + return undefined; + } +} + +export function resolveBootstrapProvider( + options: BootstrapOptions, +): BootstrapProvider { + return new DefaultBootstrapProvider(options); +} diff --git a/src/lib/util/feature-evaluator/repository/index.ts b/src/lib/util/feature-evaluator/repository/index.ts new file mode 100644 index 0000000000..ee4e9433ae --- /dev/null +++ b/src/lib/util/feature-evaluator/repository/index.ts @@ -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; +} +export interface RepositoryOptions { + appName: string; + bootstrapProvider: BootstrapProvider; + storageProvider: StorageProvider; +} + +interface FeatureToggleData { + [key: string]: FeatureInterface; +} + +export default class Repository { + private timer: NodeJS.Timer | undefined; + + private appName: string; + + private bootstrapProvider: BootstrapProvider; + + private storageProvider: StorageProvider; + + private data: FeatureToggleData = {}; + + private segments: Map; + + constructor({ + appName, + bootstrapProvider, + storageProvider, + }: RepositoryOptions) { + this.appName = appName; + this.bootstrapProvider = bootstrapProvider; + this.storageProvider = storageProvider; + this.segments = new Map(); + } + + start(): Promise { + return this.loadBootstrap(); + } + + createSegmentLookup(segments: Segment[] | undefined): Map { + if (!segments) { + return new Map(); + } + return new Map(segments.map((segment) => [segment.id, segment])); + } + + async save(response: ClientFeaturesResponse): Promise { + 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 { + 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]); + } +} diff --git a/src/lib/util/feature-evaluator/repository/storage-provider-in-mem.ts b/src/lib/util/feature-evaluator/repository/storage-provider-in-mem.ts new file mode 100644 index 0000000000..707f4571c2 --- /dev/null +++ b/src/lib/util/feature-evaluator/repository/storage-provider-in-mem.ts @@ -0,0 +1,14 @@ +import { StorageProvider } from './storage-provider'; + +export default class InMemStorageProvider implements StorageProvider { + private store: Map = new Map(); + + async set(key: string, data: T): Promise { + this.store.set(key, data); + return Promise.resolve(); + } + + async get(key: string): Promise { + return Promise.resolve(this.store.get(key)); + } +} diff --git a/src/lib/util/feature-evaluator/repository/storage-provider.ts b/src/lib/util/feature-evaluator/repository/storage-provider.ts new file mode 100644 index 0000000000..51118e3314 --- /dev/null +++ b/src/lib/util/feature-evaluator/repository/storage-provider.ts @@ -0,0 +1,60 @@ +import { join } from 'path'; +import { promises } from 'fs'; +import { safeName } from '../helpers'; + +const { writeFile, readFile } = promises; + +export interface StorageProvider { + set(key: string, data: T): Promise; + get(key: string): Promise; +} + +export interface StorageOptions { + backupPath: string; +} + +export class FileStorageProvider implements StorageProvider { + 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 { + return writeFile(this.getPath(key), JSON.stringify(data)); + } + + async get(key: string): Promise { + 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; + } + } +} diff --git a/src/lib/util/feature-evaluator/strategy/application-hostname-strategy.ts b/src/lib/util/feature-evaluator/strategy/application-hostname-strategy.ts new file mode 100644 index 0000000000..151dc5abca --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/application-hostname-strategy.ts @@ -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); + } +} diff --git a/src/lib/util/feature-evaluator/strategy/default-strategy.ts b/src/lib/util/feature-evaluator/strategy/default-strategy.ts new file mode 100644 index 0000000000..137d348597 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/default-strategy.ts @@ -0,0 +1,11 @@ +import { Strategy } from './strategy'; + +export default class DefaultStrategy extends Strategy { + constructor() { + super('default'); + } + + isEnabled(): boolean { + return true; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/flexible-rollout-strategy.ts b/src/lib/util/feature-evaluator/strategy/flexible-rollout-strategy.ts new file mode 100644 index 0000000000..97da0f04e7 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/flexible-rollout-strategy.ts @@ -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; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/gradual-rollout-random.ts b/src/lib/util/feature-evaluator/strategy/gradual-rollout-random.ts new file mode 100644 index 0000000000..a55dbfbfd6 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/gradual-rollout-random.ts @@ -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; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/gradual-rollout-session-id.ts b/src/lib/util/feature-evaluator/strategy/gradual-rollout-session-id.ts new file mode 100644 index 0000000000..391c5b62c1 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/gradual-rollout-session-id.ts @@ -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; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/gradual-rollout-user-id.ts b/src/lib/util/feature-evaluator/strategy/gradual-rollout-user-id.ts new file mode 100644 index 0000000000..e4edd7ca82 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/gradual-rollout-user-id.ts @@ -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; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/index.ts b/src/lib/util/feature-evaluator/strategy/index.ts new file mode 100644 index 0000000000..727662dce7 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/index.ts @@ -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 = [ + new DefaultStrategy(), + new ApplicationHostnameStrategy(), + new GradualRolloutRandomStrategy(), + new GradualRolloutUserIdStrategy(), + new GradualRolloutSessionIdStrategy(), + new UserWithIdStrategy(), + new RemoteAddressStrategy(), + new FlexibleRolloutStrategy(), + new UnknownStrategy(), +]; diff --git a/src/lib/util/feature-evaluator/strategy/remote-address-strategy.ts b/src/lib/util/feature-evaluator/strategy/remote-address-strategy.ts new file mode 100644 index 0000000000..5172c77c00 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/remote-address-strategy.ts @@ -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; + }, + ); + } +} diff --git a/src/lib/util/feature-evaluator/strategy/strategy.ts b/src/lib/util/feature-evaluator/strategy/strategy.ts new file mode 100644 index 0000000000..2aad945d63 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/strategy.ts @@ -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, + ): { 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, + 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, + }; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/unknown-strategy.ts b/src/lib/util/feature-evaluator/strategy/unknown-strategy.ts new file mode 100644 index 0000000000..90ca87a389 --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/unknown-strategy.ts @@ -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, + 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, + }; + } +} diff --git a/src/lib/util/feature-evaluator/strategy/user-with-id-strategy.ts b/src/lib/util/feature-evaluator/strategy/user-with-id-strategy.ts new file mode 100644 index 0000000000..2dd5273e0d --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/user-with-id-strategy.ts @@ -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); + } +} diff --git a/src/lib/util/feature-evaluator/strategy/util.ts b/src/lib/util/feature-evaluator/strategy/util.ts new file mode 100644 index 0000000000..c3ce7434ed --- /dev/null +++ b/src/lib/util/feature-evaluator/strategy/util.ts @@ -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; +} diff --git a/src/lib/util/feature-evaluator/variant.ts b/src/lib/util/feature-evaluator/variant.ts new file mode 100644 index 0000000000..ccb791f063 --- /dev/null +++ b/src/lib/util/feature-evaluator/variant.ts @@ -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; +} diff --git a/src/lib/util/offline-unleash-client.test.ts b/src/lib/util/offline-unleash-client.test.ts index 03526c3ad1..0181d69378 100644 --- a/src/lib/util/offline-unleash-client.test.ts +++ b/src/lib/util/offline-unleash-client.test.ts @@ -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 => { + 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), + ); }); }); diff --git a/src/lib/util/offline-unleash-client.ts b/src/lib/util/offline-unleash-client.ts index 2d13bccd24..ae5197bcca 100644 --- a/src/lib/util/offline-unleash-client.ts +++ b/src/lib/util/offline-unleash-client.ts @@ -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[]]; -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, - context: SdkContextSchema, - logError: (message: any, ...args: any[]) => void, -): Promise => { - const client = new UnleashClient({ +export const mapSegmentsForBootstrap = (segments: ISegment[]): Segment[] => + serializeDates(segments) as Segment[]; + +export type ClientInitOptions = { + features: NonEmptyList; + segments?: ISegment[]; + context: SdkContextSchema; + logError: (message: any, ...args: any[]) => void; +}; + +export const offlineUnleashClient = async ({ + features, + context, + segments, +}: ClientInitOptions): Promise => { + 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; }; diff --git a/src/migrations/20220808084524-add-group-permissions.js b/src/migrations/20220808084524-add-group-permissions.js new file mode 100644 index 0000000000..2ee878a31e --- /dev/null +++ b/src/migrations/20220808084524-add-group-permissions.js @@ -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, + ); +}; diff --git a/src/migrations/20220808110415-add-projects-foreign-key.js b/src/migrations/20220808110415-add-projects-foreign-key.js new file mode 100644 index 0000000000..e88fe3d40f --- /dev/null +++ b/src/migrations/20220808110415-add-projects-foreign-key.js @@ -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, + ); +}; diff --git a/src/test/arbitraries.test.ts b/src/test/arbitraries.test.ts index f248aa614a..d99e92e42f 100644 --- a/src/test/arbitraries.test.ts +++ b/src/test/arbitraries.test.ts @@ -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 => fc @@ -28,32 +29,55 @@ export const commonISOTimestamp = (): Arbitrary => }) .map((timestamp) => timestamp.toISOString()); +export const strategyConstraint = (): Arbitrary => + 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 => - 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>, + parameters?: Arbitrary>, ): Arbitrary => + 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 => fc.record({ - name: fc.constant(name), - parameters, + id: fc.integer({ min: 1 }), + name: urlFriendlyString(), constraints: strategyConstraints(), }); export const strategies = (): Arbitrary => - fc.array( + fc.uniqueArray( fc.oneof( - strategy('default', fc.constant({})), + strategy('default'), strategy( 'flexibleRollout', fc.record({ @@ -89,7 +113,16 @@ export const strategies = (): Arbitrary => 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 => @@ -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', () => diff --git a/src/test/e2e/api/admin/bootstrap.test.ts b/src/test/e2e/api/admin/bootstrap.test.ts deleted file mode 100644 index 2f70a44643..0000000000 --- a/src/test/e2e/api/admin/bootstrap.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/test/e2e/api/admin/config.e2e.test.ts b/src/test/e2e/api/admin/config.e2e.test.ts index 895953e70a..b4812fa2d5 100644 --- a/src/test/e2e/api/admin/config.e2e.test.ts +++ b/src/test/e2e/api/admin/config.e2e.test.ts @@ -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/) diff --git a/src/test/e2e/api/admin/event.e2e.test.ts b/src/test/e2e/api/admin/event.e2e.test.ts index bbee66737a..055f599370 100644 --- a/src/test/e2e/api/admin/event.e2e.test.ts +++ b/src/test/e2e/api/admin/event.e2e.test.ts @@ -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); + }); +}); diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 814751df4c..b45e91d8c2 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -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, - strategy: Omit = defaultStrategy, + toggle: FeatureToggleDTO, + strategy: Omit = 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 diff --git a/src/test/e2e/api/admin/playground.e2e.test.ts b/src/test/e2e/api/admin/playground.e2e.test.ts index fc9a187a21..96d48e05d1 100644 --- a/src/test/e2e/api/admin/playground.e2e.test.ts +++ b/src/test/e2e/api/admin/playground.e2e.test.ts @@ -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']; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 6b128bce34..e1ba6d7c4c 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts index 0753f899ff..148ea63956 100644 --- a/src/test/e2e/services/access-service.e2e.test.ts +++ b/src/test/e2e/services/access-service.e2e.test.ts @@ -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); }); diff --git a/src/test/e2e/services/playground-service.test.ts b/src/test/e2e/services/playground-service.test.ts index 899c0ad74b..16b38fa97e 100644 --- a/src/test/e2e/services/playground-service.test.ts +++ b/src/test/e2e/services/playground-service.test.ts @@ -1,5 +1,8 @@ import { PlaygroundService } from '../../../lib/services/playground-service'; -import { clientFeatures } from '../../arbitraries.test'; +import { + clientFeaturesAndSegments, + commonISOTimestamp, +} from '../../arbitraries.test'; import { generate as generateContext } from '../../../lib/openapi/spec/sdk-context-schema.test'; import fc from 'fast-check'; import { createTestConfig } from '../../config/test-config'; @@ -7,27 +10,34 @@ import dbInit, { ITestDb } from '../helpers/database-init'; import { IUnleashStores } from '../../../lib/types/stores'; import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import { SegmentService } from '../../../lib/services/segment-service'; -import { FeatureToggleDTO, IVariant } from '../../../lib/types/model'; +import { FeatureToggle, ISegment, WeightType } from '../../../lib/types/model'; import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema'; -import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client'; -import { ClientFeatureSchema } from '../../../lib/openapi/spec/client-feature-schema'; +import { offlineUnleashClientNode } from '../../../lib/util/offline-unleash-client.test'; +import { ClientFeatureSchema } from 'lib/openapi/spec/client-feature-schema'; +import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema'; +import { SegmentSchema } from 'lib/openapi/spec/segment-schema'; +import { playgroundStrategyEvaluation } from '../../../lib/openapi/spec/playground-strategy-schema'; +import { PlaygroundSegmentSchema } from 'lib/openapi/spec/playground-segment-schema'; let stores: IUnleashStores; let db: ITestDb; let service: PlaygroundService; let featureToggleService: FeatureToggleService; +let segmentService: SegmentService; beforeAll(async () => { const config = createTestConfig(); db = await dbInit('playground_service_serial', config.getLogger); stores = db.stores; + segmentService = new SegmentService(stores, config); featureToggleService = new FeatureToggleService( stores, config, - new SegmentService(stores, config), + segmentService, ); service = new PlaygroundService(config, { featureToggleServiceV2: featureToggleService, + segmentService, }); }); @@ -35,11 +45,120 @@ afterAll(async () => { await db.destroy(); }); +const cleanup = async () => { + await stores.segmentStore.deleteAll(); + await stores.featureToggleStore.deleteAll(); + await stores.eventStore.deleteAll(); + await stores.featureStrategiesStore.deleteAll(); + await stores.segmentStore.deleteAll(); +}; + +afterEach(cleanup); + const testParams = { interruptAfterTimeLimit: 4000, // Default timeout in Jest 5000ms markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure }; +const mapSegmentSchemaToISegment = ( + segment: SegmentSchema, + index?: number, +): ISegment => ({ + ...segment, + name: segment.name || `test-segment ${index ?? 'unnumbered'}`, + createdAt: new Date(), +}); + +export const seedDatabaseForPlaygroundTest = async ( + database: ITestDb, + features: ClientFeatureSchema[], + environment: string, + segments?: SegmentSchema[], +): Promise => { + if (segments) { + await Promise.all( + segments.map(async (segment, index) => + database.stores.segmentStore.create( + mapSegmentSchemaToISegment(segment, index), + { username: 'test' }, + ), + ), + ); + } + + return Promise.all( + features.map(async (feature) => { + // create feature + const toggle = await database.stores.featureToggleStore.create( + feature.project, + { + ...feature, + createdAt: undefined, + variants: [ + ...(feature.variants ?? []).map((variant) => ({ + ...variant, + weightType: WeightType.VARIABLE, + stickiness: 'default', + })), + ], + }, + ); + + // create environment if necessary + await database.stores.environmentStore + .create({ + name: environment, + type: 'development', + enabled: true, + }) + .catch(() => { + // purposefully left empty: env creation may fail if the + // env already exists, and because of the async nature + // of things, this is the easiest way to make it work. + }); + + // assign strategies + await Promise.all( + (feature.strategies || []).map( + async ({ segments: strategySegments, ...strategy }) => { + await database.stores.featureStrategiesStore.createStrategyFeatureEnv( + { + parameters: {}, + constraints: [], + ...strategy, + featureName: feature.name, + environment, + strategyName: strategy.name, + projectId: feature.project, + }, + ); + + if (strategySegments) { + await Promise.all( + strategySegments.map((segmentId) => + database.stores.segmentStore.addToStrategy( + segmentId, + strategy.id, + ), + ), + ); + } + }, + ), + ); + + // enable/disable the feature in environment + await database.stores.featureEnvironmentStore.addEnvironmentToFeature( + feature.name, + environment, + feature.enabled, + ); + + return toggle; + }), + ); +}; + describe('the playground service (e2e)', () => { const isDisabledVariant = ({ name, @@ -49,36 +168,50 @@ describe('the playground service (e2e)', () => { enabled: boolean; }) => name === 'disabled' && !enabled; - const toFeatureToggleDTO = ( - feature: ClientFeatureSchema, - ): FeatureToggleDTO => ({ - ...feature, - // the arbitrary generator takes care of this - variants: feature.variants as IVariant[] | undefined, - createdAt: undefined, - }); + const insertAndEvaluateFeatures = async ({ + features, + context, + env = 'default', + segments, + }: { + features: ClientFeatureSchema[]; + context: SdkContextSchema; + env?: string; + segments?: SegmentSchema[]; + }): Promise => { + await seedDatabaseForPlaygroundTest(db, features, env, segments); + + // const activeSegments = await db.stores.segmentStore.getAllFeatureStrategySegments() + // console.log("active segments db seeding", activeSegments) + + const projects = '*'; + + const serviceFeatures: PlaygroundFeatureSchema[] = + await service.evaluateQuery(projects, env, context); + + return serviceFeatures; + }; test('should return the same enabled toggles as the raw SDK correctly mapped', async () => { await fc.assert( fc .asyncProperty( - clientFeatures({ minLength: 1 }), - generateContext(), - async (toggles, context) => { - await Promise.all( - toggles.map((feature) => - stores.featureToggleStore.create( - feature.project, - toFeatureToggleDTO(feature), - ), - ), - ); - - const projects = '*'; - const env = 'default'; - - const serviceToggles: PlaygroundFeatureSchema[] = - await service.evaluateQuery(projects, env, context); + clientFeaturesAndSegments({ minLength: 1 }), + fc + .tuple(generateContext(), commonISOTimestamp()) + .map(([context, currentTime]) => ({ + ...context, + userId: 'constant', + sessionId: 'constant2', + currentTime, + })), + fc.context(), + async ({ segments, features }, context, ctx) => { + const serviceToggles = await insertAndEvaluateFeatures({ + features: features, + context, + segments, + }); const [head, ...rest] = await featureToggleService.getClientFeatures(); @@ -86,11 +219,12 @@ describe('the playground service (e2e)', () => { return serviceToggles.length === 0; } - const client = await offlineUnleashClient( - [head, ...rest], + const client = await offlineUnleashClientNode({ + features: [head, ...rest], context, - console.log, - ); + logError: console.log, + segments: segments.map(mapSegmentSchemaToISegment), + }); const clientContext = { ...context, @@ -101,20 +235,56 @@ describe('the playground service (e2e)', () => { }; return serviceToggles.every((feature) => { + ctx.log( + `Examining feature ${ + feature.name + }: ${JSON.stringify(feature)}`, + ); + + // the playground differs from a normal SDK in that + // it _must_ evaluate all srategies and features + // regardless of whether they're supposed to be + // enabled in the current environment or not. + const expectedSDKState = feature.isEnabled; + const enabledStateMatches = - feature.isEnabled === + expectedSDKState === client.isEnabled(feature.name, clientContext); - // if x.isEnabled then variant should === variant.name. Otherwise it should be null + expect(enabledStateMatches).toBe(true); + + ctx.log( + `feature.isEnabled, feature.isEnabledInCurrentEnvironment, presumedSDKState: ${feature.isEnabled}, ${feature.isEnabledInCurrentEnvironment}, ${expectedSDKState}`, + ); + ctx.log( + `client.isEnabled: ${client.isEnabled( + feature.name, + clientContext, + )}`, + ); // if x is disabled, then the variant will be the // disabled variant. if (!feature.isEnabled) { + ctx.log(`${feature.name} is not enabled`); + ctx.log(JSON.stringify(feature.variant)); + ctx.log(JSON.stringify(enabledStateMatches)); + ctx.log( + JSON.stringify( + feature.variant.name === 'disabled', + ), + ); + ctx.log( + JSON.stringify( + feature.variant.enabled === false, + ), + ); return ( enabledStateMatches && isDisabledVariant(feature.variant) ); } + ctx.log('feature is enabled'); const clientVariant = client.getVariant( feature.name, @@ -124,30 +294,747 @@ describe('the playground service (e2e)', () => { // if x is enabled, but its variant is the disabled // variant, then the source does not have any // variants - if ( - feature.isEnabled && - isDisabledVariant(feature.variant) - ) { + if (isDisabledVariant(feature.variant)) { return ( enabledStateMatches && isDisabledVariant(clientVariant) ); } - return ( - enabledStateMatches && - clientVariant.name === feature.variant.name && - clientVariant.enabled === - feature.variant.enabled && - clientVariant.payload === - feature.variant.payload + ctx.log(`feature "${feature.name}" has a variant`); + ctx.log( + `Feature variant: ${JSON.stringify( + feature.variant, + )}`, + ); + ctx.log( + `Client variant: ${JSON.stringify( + clientVariant, + )}`, + ); + ctx.log( + `enabledStateMatches: ${enabledStateMatches}`, + ); + + // variants should be the same if the + // toggle is enabled in both versions. If + // they're not and one of them has a + // variant, then they should be different. + if (expectedSDKState === true) { + expect(feature.variant).toEqual(clientVariant); + } else { + expect(feature.variant).not.toEqual( + clientVariant, + ); + } + + return enabledStateMatches; + }); + }, + ) + .afterEach(cleanup), + { ...testParams, examples: [] }, + ); + }); + + // counterexamples found by fastcheck + const counterexamples = [ + [ + [ + { + name: '-', + type: 'release', + project: 'A', + enabled: true, + lastSeenAt: '1970-01-01T00:00:00.000Z', + impressionData: null, + strategies: [], + variants: [ + { + name: '-', + weight: 147, + weightType: 'variable', + stickiness: 'default', + payload: { type: 'string', value: '' }, + }, + { + name: '~3dignissim~gravidaod', + weight: 301, + weightType: 'variable', + stickiness: 'default', + payload: { + type: 'json', + value: '{"Sv7gRNNl=":[true,"Mfs >mp.D","O-jtK","y%i\\"Ub~",null,"J",false,"(\'R"],"F0g+>1X":3.892913121148499e-188,"Fi~k(":-4.882970135331098e+146,"":null,"nPT]":true}', + }, + }, + ], + }, + ], + { + appName: '"$#', + currentTime: '9999-12-31T23:59:59.956Z', + environment: 'r', + }, + { + logs: [ + 'feature is enabled', + 'feature has a variant', + '{"name":"-","payload":{"type":"string","value":""},"enabled":true}', + '{"name":"~3dignissim~gravidaod","payload":{"type":"json","value":"{\\"Sv7gRNNl=\\":[true,\\"Mfs >mp.D\\",\\"O-jtK\\",\\"y%i\\\\\\"Ub~\\",null,\\"J\\",false,\\"(\'R\\"],\\"F0g+>1X\\":3.892913121148499e-188,\\"Fi~k(\\":-4.882970135331098e+146,\\"\\":null,\\"nPT]\\":true}"},"enabled":true}', + 'true', + 'false', + ], + }, + ], + [ + [ + { + name: '-', + project: '0', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: 'A', + operator: 'NOT_IN', + caseInsensitive: false, + inverted: false, + values: [], + value: '', + }, + ], + }, + ], + }, + ], + { appName: ' ', userId: 'constant', sessionId: 'constant2' }, + { logs: [] }, + ], + [ + [ + { + name: 'a', + project: 'a', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: '0', + operator: 'NOT_IN', + caseInsensitive: false, + inverted: false, + values: [], + value: '', + }, + ], + }, + ], + }, + { + name: '-', + project: 'elementum', + enabled: false, + strategies: [], + }, + ], + { appName: ' ', userId: 'constant', sessionId: 'constant2' }, + { + logs: [ + 'feature is not enabled', + '{"name":"disabled","enabled":false}', + ], + }, + ], + [ + [ + { + name: '0', + project: '-', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: 'sed', + operator: 'NOT_IN', + caseInsensitive: false, + inverted: false, + values: [], + value: '', + }, + ], + }, + ], + }, + ], + { appName: ' ', userId: 'constant', sessionId: 'constant2' }, + { + logs: [ + '0 is not enabled', + '{"name":"disabled","enabled":false}', + 'true', + 'true', + ], + }, + ], + [ + [ + { + name: '0', + project: 'ac', + enabled: true, + + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: '0', + operator: 'NOT_IN', + caseInsensitive: false, + inverted: false, + values: [], + value: '', + }, + ], + }, + ], + }, + ], + { appName: ' ', userId: 'constant', sessionId: 'constant2' }, + { + logs: [ + 'feature.isEnabled: false', + 'client.isEnabled: true', + '0 is not enabled', + '{"name":"disabled","enabled":false}', + 'false', + 'true', + 'true', + ], + }, + ], + [ + [ + { + name: '0', + project: 'aliquam', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: '-', + operator: 'NOT_IN', + caseInsensitive: false, + inverted: false, + values: [], + value: '', + }, + ], + }, + ], + }, + { + name: '-', + project: '-', + enabled: false, + strategies: [], + }, + ], + { + appName: ' ', + userId: 'constant', + sessionId: 'constant2', + currentTime: '1970-01-01T00:00:00.000Z', + }, + { + logs: [ + 'feature.isEnabled: false', + 'client.isEnabled: true', + '0 is not enabled', + '{"name":"disabled","enabled":false}', + 'false', + 'true', + 'true', + ], + }, + ], + ]; + + // these tests test counterexamples found by fast check. The may seem redundant, but are concrete cases that might break. + counterexamples.map(async ([features, context], i) => { + it(`should do the same as the raw SDK: counterexample ${i}`, async () => { + const serviceFeatures = await insertAndEvaluateFeatures({ + // @ts-expect-error + features, + // @ts-expect-error + context, + }); + + const [head, ...rest] = + await featureToggleService.getClientFeatures(); + if (!head) { + return serviceFeatures.length === 0; + } + + const client = await offlineUnleashClientNode({ + features: [head, ...rest], + // @ts-expect-error + context, + logError: console.log, + }); + + const clientContext = { + ...context, + + // @ts-expect-error + currentTime: context.currentTime + ? // @ts-expect-error + new Date(context.currentTime) + : undefined, + }; + + serviceFeatures.forEach((feature) => { + expect(feature.isEnabled).toEqual( + //@ts-expect-error + client.isEnabled(feature.name, clientContext), + ); + }); + }); + }); + + test("should return all of a feature's strategies", async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }), + generateContext(), + fc.context(), + async (data, context, ctx) => { + const log = (x: unknown) => ctx.log(JSON.stringify(x)); + const serviceFeatures = await insertAndEvaluateFeatures( + { + ...data, + context, + }, + ); + + const serviceFeaturesDict: { + [key: string]: PlaygroundFeatureSchema; + } = serviceFeatures.reduce( + (acc, feature) => ({ + ...acc, + [feature.name]: feature, + }), + {}, + ); + + // for each feature, find the corresponding evaluated feature + // and make sure that the evaluated + // return genFeat.length === servFeat.length && zip(gen, serv). + data.features.forEach((feature) => { + const mappedFeature: PlaygroundFeatureSchema = + serviceFeaturesDict[feature.name]; + + // log(feature); + log(mappedFeature); + + const featureStrategies = feature.strategies ?? []; + + expect( + mappedFeature.strategies.data.length, + ).toEqual(featureStrategies.length); + + // we can't guarantee that the order we inserted + // strategies into the database is the same as it + // was returned by the service , so we'll need to + // scan through the list of strats. + + // extract the `result` property, because it + // doesn't exist in the input + + const removeResult = ({ + result, + ...rest + }: T & { + result: unknown; + }) => rest; + + const cleanedReceivedStrategies = + mappedFeature.strategies.data.map( + (strategy) => { + const { + segments: mappedSegments, + ...mappedStrategy + } = removeResult(strategy); + + return { + ...mappedStrategy, + constraints: + mappedStrategy.constraints?.map( + removeResult, + ), + }; + }, + ); + + feature.strategies.forEach( + ({ segments, ...strategy }) => { + expect(cleanedReceivedStrategies).toEqual( + expect.arrayContaining([ + { + ...strategy, + constraints: + strategy.constraints ?? [], + parameters: + strategy.parameters ?? {}, + }, + ]), + ); + }, ); }); }, ) - .afterEach(async () => { - await stores.featureToggleStore.deleteAll(); - }), + .afterEach(cleanup), + testParams, + ); + }); + + test('should return feature strategies with all their segments', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }), + generateContext(), + async ( + { segments, features: generatedFeatures }, + context, + ) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + features: generatedFeatures, + context, + segments, + }, + ); + + const serviceFeaturesDict: { + [key: string]: PlaygroundFeatureSchema; + } = serviceFeatures.reduce( + (acc, feature) => ({ + ...acc, + [feature.name]: feature, + }), + {}, + ); + + // ensure that segments are mapped on to features + // correctly. We do not need to check whether the + // evaluation is correct; that is taken care of by other + // tests. + + // For each feature strategy, find its list of segments and + // compare it to the input. + // + // We can assert three things: + // + // 1. The segments lists have the same length + // + // 2. All segment ids listed in an input id list are + // also in the original segments list + // + // 3. If a feature is considered enabled, _all_ segments + // must be true. If a feature is _disabled_, _at least_ + // one segment is not true. + generatedFeatures.forEach((unmappedFeature) => { + const strategies = serviceFeaturesDict[ + unmappedFeature.name + ].strategies.data.reduce( + (acc, strategy) => ({ + ...acc, + [strategy.id]: strategy, + }), + {}, + ); + + unmappedFeature.strategies?.forEach( + (unmappedStrategy) => { + const mappedStrategySegments: PlaygroundSegmentSchema[] = + strategies[unmappedStrategy.id] + .segments; + + const unmappedSegments = + unmappedStrategy.segments ?? []; + + // 1. The segments lists have the same length + // 2. All segment ids listed in the input exist: + expect( + [ + ...mappedStrategySegments?.map( + (segment) => segment.id, + ), + ].sort(), + ).toEqual([...unmappedSegments].sort()); + + switch ( + strategies[unmappedStrategy.id].result + ) { + case true: + // If a strategy is considered true, _all_ segments + // must be true. + expect( + mappedStrategySegments.every( + (segment) => + segment.result === true, + ), + ).toBeTruthy(); + case false: + // empty -- all segments can be true and + // the toggle still not enabled. We + // can't check for anything here. + case 'not found': + // empty -- we can't evaluate this + } + }, + ); + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test("should evaluate a strategy to be unknown if it doesn't recognize the strategy and all constraints pass", async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }).map( + ({ features, ...rest }) => ({ + ...rest, + features: features.map((feature) => ({ + ...feature, + // remove any constraints and use a name that doesn't exist + strategies: feature.strategies.map( + (strategy) => ({ + ...strategy, + name: 'bogus-strategy', + constraints: [], + segments: [], + }), + ), + })), + }), + ), + generateContext(), + fc.context(), + async (featsAndSegments, context, ctx) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + ...featsAndSegments, + context, + }, + ); + + serviceFeatures.forEach((feature) => + feature.strategies.data.forEach((strategy) => { + expect(strategy.result.evaluationStatus).toBe( + playgroundStrategyEvaluation.evaluationIncomplete, + ); + expect(strategy.result.enabled).toBe( + playgroundStrategyEvaluation.unknownResult, + ); + }), + ); + + ctx.log(JSON.stringify(serviceFeatures)); + serviceFeatures.forEach((feature) => { + // if there are strategies and they're all + // incomplete and unknown, then the feature can't be + // evaluated fully + if (feature.strategies.data.length) { + expect(feature.isEnabled).toBe(false); + } + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test("should evaluate a strategy as false if it doesn't recognize the strategy and constraint checks fail", async () => { + await fc.assert( + fc + .asyncProperty( + fc + .tuple( + fc.uuid(), + clientFeaturesAndSegments({ minLength: 1 }), + ) + .map(([uuid, { features, ...rest }]) => ({ + ...rest, + features: features.map((feature) => ({ + ...feature, + // use a constraint that will never be true + strategies: feature.strategies.map( + (strategy) => ({ + ...strategy, + name: 'bogusStrategy', + constraints: [ + { + contextName: 'appName', + operator: 'IN' as 'IN', + values: [uuid], + }, + ], + }), + ), + })), + })), + generateContext(), + fc.context(), + async (featsAndSegments, context, ctx) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + ...featsAndSegments, + context, + }, + ); + + serviceFeatures.forEach((feature) => + feature.strategies.data.forEach((strategy) => { + expect(strategy.result.evaluationStatus).toBe( + playgroundStrategyEvaluation.evaluationIncomplete, + ); + expect(strategy.result.enabled).toBe(false); + }), + ); + + ctx.log(JSON.stringify(serviceFeatures)); + + serviceFeatures.forEach((feature) => { + if (feature.strategies.data.length) { + // if there are strategies and they're all + // incomplete and false, then the feature + // is also false + expect(feature.isEnabled).toEqual(false); + } + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test('should evaluate a feature as unknown if there is at least one incomplete strategy among all failed strategies', async () => { + await fc.assert( + fc + .asyncProperty( + fc + .tuple( + fc.uuid(), + clientFeaturesAndSegments({ minLength: 1 }), + ) + .map(([uuid, { features, ...rest }]) => ({ + ...rest, + features: features.map((feature) => ({ + ...feature, + // use a constraint that will never be true + strategies: [ + ...feature.strategies.map((strategy) => ({ + ...strategy, + constraints: [ + { + contextName: 'appName', + operator: 'IN' as 'IN', + values: [uuid], + }, + ], + })), + { name: 'my-custom-strategy' }, + ], + })), + })), + generateContext(), + async (featsAndSegments, context) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + ...featsAndSegments, + context, + }, + ); + + serviceFeatures.forEach((feature) => { + if (feature.strategies.data.length) { + // if there are strategies and they're + // all incomplete and unknown, then + // the feature is also unknown and + // thus 'false' (from an SDK point of + // view) + expect(feature.isEnabled).toEqual(false); + } + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test("features can't be evaluated to true if they're not enabled in the current environment", async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }).map( + ({ features, ...rest }) => ({ + ...rest, + features: features.map((feature) => ({ + ...feature, + enabled: false, + // remove any constraints and use a name that doesn't exist + strategies: [{ name: 'default' }], + })), + }), + ), + generateContext(), + fc.context(), + async (featsAndSegments, context, ctx) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + ...featsAndSegments, + context, + }, + ); + + serviceFeatures.forEach((feature) => + feature.strategies.data.forEach((strategy) => { + expect(strategy.result.evaluationStatus).toBe( + playgroundStrategyEvaluation.evaluationComplete, + ); + expect(strategy.result.enabled).toBe(true); + }), + ); + + ctx.log(JSON.stringify(serviceFeatures)); + serviceFeatures.forEach((feature) => { + expect(feature.isEnabled).toBe(false); + expect(feature.isEnabledInCurrentEnvironment).toBe( + false, + ); + }); + }, + ) + .afterEach(cleanup), testParams, ); }); @@ -156,25 +1043,18 @@ describe('the playground service (e2e)', () => { await fc.assert( fc .asyncProperty( - clientFeatures({ minLength: 1 }), + clientFeaturesAndSegments({ minLength: 1 }), generateContext(), - async (toggles, context) => { - await Promise.all( - toggles.map((feature) => - stores.featureToggleStore.create( - feature.project, - toFeatureToggleDTO(feature), - ), - ), + async ({ features, segments }, context) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + features, + segments, + context, + }, ); - const projects = '*'; - const env = 'default'; - - const serviceToggles: PlaygroundFeatureSchema[] = - await service.evaluateQuery(projects, env, context); - - const variantsMap = toggles.reduce( + const variantsMap = features.reduce( (acc, feature) => ({ ...acc, [feature.name]: feature.variants, @@ -182,7 +1062,7 @@ describe('the playground service (e2e)', () => { {}, ); - serviceToggles.forEach((feature) => { + serviceFeatures.forEach((feature) => { if (variantsMap[feature.name]) { expect(feature.variants).toEqual( expect.arrayContaining( @@ -198,9 +1078,135 @@ describe('the playground service (e2e)', () => { }); }, ) - .afterEach(async () => { - await stores.featureToggleStore.deleteAll(); - }), + .afterEach(cleanup), + testParams, + ); + }); + + test('isEnabled matches strategies.results', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }), + generateContext(), + async ({ features, segments }, context) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + features, + segments, + context, + }, + ); + + serviceFeatures.forEach((feature) => { + if (feature.isEnabled) { + expect( + feature.isEnabledInCurrentEnvironment, + ).toBe(true); + expect(feature.strategies.result).toBe(true); + } else { + expect( + !feature.isEnabledInCurrentEnvironment || + feature.strategies.result !== true, + ).toBe(true); + } + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test('strategies.results matches the individual strategy results', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }), + generateContext(), + async ({ features, segments }, context) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + features, + segments, + context, + }, + ); + + serviceFeatures.forEach(({ strategies }) => { + if (strategies.result === false) { + expect( + strategies.data.every( + (strategy) => + strategy.result.enabled === false, + ), + ).toBe(true); + } else if ( + strategies.result === + playgroundStrategyEvaluation.unknownResult + ) { + expect( + strategies.data.some( + (strategy) => + strategy.result.enabled === + playgroundStrategyEvaluation.unknownResult, + ), + ).toBe(true); + + expect( + strategies.data.every( + (strategy) => + strategy.result.enabled !== true, + ), + ).toBe(true); + } else { + if (strategies.data.length > 0) { + expect( + strategies.data.some( + (strategy) => + strategy.result.enabled === + true, + ), + ).toBe(true); + } + } + }); + }, + ) + .afterEach(cleanup), + testParams, + ); + }); + + test('unevaluated features should not have variants', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeaturesAndSegments({ minLength: 1 }), + generateContext(), + async ({ features, segments }, context) => { + const serviceFeatures = await insertAndEvaluateFeatures( + { + features, + segments, + context, + }, + ); + + serviceFeatures.forEach((feature) => { + if ( + feature.strategies.result === + playgroundStrategyEvaluation.unknownResult + ) { + expect(feature.variant).toEqual({ + name: 'disabled', + enabled: false, + }); + } + }); + }, + ) + .afterEach(cleanup), testParams, ); }); diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 4c3a9030a1..df43e62698 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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: [], diff --git a/src/test/e2e/services/setting-service.test.ts b/src/test/e2e/services/setting-service.test.ts index ad0e47f860..6c6e7c503f 100644 --- a/src/test/e2e/services/setting-service.test.ts +++ b/src/test/e2e/services/setting-service.test.ts @@ -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); }); diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 6fff806f19..5ca37bbedc 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -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); }); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 8fb83b1243..1155bc2056 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -11,10 +11,6 @@ class FakeEventStore extends EventEmitter implements IEventStore { this.events = []; } - async getEventsForFeature(featureName: string): Promise { - return this.events.filter((e) => e.featureName === featureName); - } - store(event: IEvent): Promise { 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 { - return this.events.filter((e) => e.type === type); - } - - async getEventsFilterByProject(project: string): Promise { - return this.events.filter((e) => e.project === project); + async searchEvents(): Promise { + throw new Error('Method not implemented.'); } } diff --git a/src/test/fixtures/fake-group-store.ts b/src/test/fixtures/fake-group-store.ts index fbbace4367..c4511231fe 100644 --- a/src/test/fixtures/fake-group-store.ts +++ b/src/test/fixtures/fake-group-store.ts @@ -50,13 +50,6 @@ export default class FakeGroupStore implements IGroupStore { throw new Error('Method not implemented.'); } - updateExistingUsersInGroup( - id: number, - users: IGroupUserModel[], - ): Promise { - throw new Error('Method not implemented.'); - } - getAllUsersByGroups(groupIds: number[]): Promise { throw new Error('Method not implemented.'); } diff --git a/website/docs/advanced/impression-data.md b/website/docs/advanced/impression-data.md index 117f9045c9..f0c7ce9e8c 100644 --- a/website/docs/advanced/impression-data.md +++ b/website/docs/advanced/impression-data.md @@ -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); -}) +}); ``` diff --git a/website/docs/api/admin/events-api.md b/website/docs/api/admin/events-api.md index 9c1ce3cc8a..05ef65b5ef 100644 --- a/website/docs/api/admin/events-api.md +++ b/website/docs/api/admin/events-api.md @@ -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. diff --git a/website/docs/how-to/how-to-create-and-manage-user-groups.md b/website/docs/how-to/how-to-create-and-manage-user-groups.md index bb991908c3..baff9f2626 100644 --- a/website/docs/how-to/how-to-create-and-manage-user-groups.md +++ b/website/docs/how-to/how-to-create-and-manage-user-groups.md @@ -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 diff --git a/website/docs/how-to/how-to-use-custom-strategies.md b/website/docs/how-to/how-to-use-custom-strategies.md index ef3cb52bc0..9ebf424adf 100644 --- a/website/docs/how-to/how-to-use-custom-strategies.md +++ b/website/docs/how-to/how-to-use-custom-strategies.md @@ -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`), ); ``` diff --git a/website/docs/sdks/index.md b/website/docs/sdks/index.md index 359810b43d..d70447381f 100644 --- a/website/docs/sdks/index.md +++ b/website/docs/sdks/index.md @@ -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 | ✅ | ✅ | ✅ | ⭕ | ✅ | ⭕ | ✅ | ⭕ | ✅ | diff --git a/website/docs/user_guide/quickstart.md b/website/docs/user_guide/quickstart.md index c6d38f7fbe..63373a9cc2 100644 --- a/website/docs/user_guide/quickstart.md +++ b/website/docs/user_guide/quickstart.md @@ -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 \ diff --git a/website/docs/user_guide/rbac.md b/website/docs/user_guide/rbac.md index 46ab6cae51..695f9d7ea9 100644 --- a/website/docs/user_guide/rbac.md +++ b/website/docs/user_guide/rbac.md @@ -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. diff --git a/website/package.json b/website/package.json index 20b578bd39..dcc7686400 100644 --- a/website/package.json +++ b/website/package.json @@ -19,18 +19,18 @@ "build-storybook": "build-storybook" }, "dependencies": { - "@docusaurus/core": "2.0.0-rc.1", - "@docusaurus/plugin-client-redirects": "2.0.0-rc.1", - "@docusaurus/plugin-google-analytics": "2.0.0-rc.1", - "@docusaurus/preset-classic": "2.0.0-rc.1", - "@docusaurus/remark-plugin-npm2yarn": "2.0.0-rc.1", + "@docusaurus/core": "2.0.1", + "@docusaurus/plugin-client-redirects": "2.0.1", + "@docusaurus/plugin-google-analytics": "2.0.1", + "@docusaurus/preset-classic": "2.0.1", + "@docusaurus/remark-plugin-npm2yarn": "2.0.1", "@mdx-js/react": "1.6.22", "@svgr/webpack": "6.3.1", "clsx": "1.2.1", "file-loader": "6.2.0", "react": "18.2.0", "react-dom": "18.2.0", - "unleash-proxy-client": "2.0.3", + "unleash-proxy-client": "2.1.0", "url-loader": "4.1.1" }, "resolutions": { @@ -55,13 +55,13 @@ ] }, "devDependencies": { - "@babel/core": "7.18.9", - "@docusaurus/module-type-aliases": "2.0.0-rc.1", - "@storybook/addon-actions": "6.5.9", - "@storybook/addon-essentials": "6.5.9", - "@storybook/addon-interactions": "6.5.9", - "@storybook/addon-links": "6.5.9", - "@storybook/react": "6.5.9", + "@babel/core": "7.18.10", + "@docusaurus/module-type-aliases": "2.0.1", + "@storybook/addon-actions": "6.5.10", + "@storybook/addon-essentials": "6.5.10", + "@storybook/addon-interactions": "6.5.10", + "@storybook/addon-links": "6.5.10", + "@storybook/react": "6.5.10", "@storybook/testing-library": "0.0.13", "@tsconfig/docusaurus": "1.0.6", "babel-loader": "8.2.5", diff --git a/website/static/img/add-user-to-group-step-1.png b/website/static/img/add-user-to-group-step-1.png index 780d9f2e7c..b2e301c8a7 100644 Binary files a/website/static/img/add-user-to-group-step-1.png and b/website/static/img/add-user-to-group-step-1.png differ diff --git a/website/static/img/add-user-to-group-step-2.png b/website/static/img/add-user-to-group-step-2.png index eb7fd5bffd..306d567974 100644 Binary files a/website/static/img/add-user-to-group-step-2.png and b/website/static/img/add-user-to-group-step-2.png differ diff --git a/website/static/img/add-user-to-group-step-3.png b/website/static/img/add-user-to-group-step-3.png index f3ec97eb87..2fc70fde3a 100644 Binary files a/website/static/img/add-user-to-group-step-3.png and b/website/static/img/add-user-to-group-step-3.png differ diff --git a/website/static/img/create-ug-step-3.png b/website/static/img/create-ug-step-3.png index 0a60aa2ab9..57158ae479 100644 Binary files a/website/static/img/create-ug-step-3.png and b/website/static/img/create-ug-step-3.png differ diff --git a/website/static/img/create-ug-step-4.png b/website/static/img/create-ug-step-4.png index 87d50f3424..0c3ad1432a 100644 Binary files a/website/static/img/create-ug-step-4.png and b/website/static/img/create-ug-step-4.png differ diff --git a/website/static/img/remove-user-from-group-step-1.png b/website/static/img/remove-user-from-group-step-1.png index f6e36b899a..afb40186a2 100644 Binary files a/website/static/img/remove-user-from-group-step-1.png and b/website/static/img/remove-user-from-group-step-1.png differ diff --git a/website/static/img/remove-user-from-group-step-2.png b/website/static/img/remove-user-from-group-step-2.png index e9ae1e801b..14b1682cd6 100644 Binary files a/website/static/img/remove-user-from-group-step-2.png and b/website/static/img/remove-user-from-group-step-2.png differ diff --git a/yarn.lock b/yarn.lock index 75a089bf65..3ea69e02d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -50,7 +50,7 @@ call-me-maybe "^1.0.1" z-schema "^5.0.1" -"@apidevtools/swagger-parser@^10.1.0": +"@apidevtools/swagger-parser@10.1.0": version "10.1.0" resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.1.0.tgz#a987d71e5be61feb623203be0c96e5985b192ab6" integrity sha512-9Kt7EuS/7WbMAUv2gSziqjvxwDbFSg3Xeyfuj5laUODX8o/k/CpsAKiQ8W7/R88eXFTMbJYg6+7uAmOWNKmwnw== @@ -101,21 +101,21 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== -"@babel/core@7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.9.tgz#805461f967c77ff46c74ca0460ccf4fe933ddd59" - integrity sha512-1LIb1eL8APMy91/IMW+31ckrfBM4yCoLaVzoDhZUKSM4cu1L1nIidyxkCgzPAgrC5WEz36IPEr/eSeSF9pIn+g== +"@babel/core@7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" + integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.9" + "@babel/generator" "^7.18.10" "@babel/helper-compilation-targets" "^7.18.9" "@babel/helper-module-transforms" "^7.18.9" "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.9" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/parser" "^7.18.10" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.18.10" + "@babel/types" "^7.18.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -152,6 +152,15 @@ "@jridgewell/gen-mapping" "^0.1.0" jsesc "^2.5.1" +"@babel/generator@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.10.tgz#794f328bfabdcbaf0ebf9bf91b5b57b61fa77a2a" + integrity sha512-0+sW7e3HjQbiHbj1NeU/vN8ornohYlacAfZIaXhdoGweQqgcNy69COVciYYqEXJ/v+9OBA7Frxm4CVAuNqKeNA== + dependencies: + "@babel/types" "^7.18.10" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + "@babel/generator@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" @@ -303,6 +312,11 @@ dependencies: "@babel/types" "^7.18.6" +"@babel/helper-string-parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" + integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== + "@babel/helper-validator-identifier@^7.14.5": version "7.14.9" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz" @@ -378,6 +392,11 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz" integrity sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ== +"@babel/parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.10.tgz#94b5f8522356e69e8277276adf67ed280c90ecc1" + integrity sha512-TYk3OA0HKL6qNryUayb5UUEhM/rkOQozIBEA5ITXh5DWrSp0TlUQXMyZmnWxG/DizSWBeeQ0Zbc5z8UGaaqoeg== + "@babel/parser@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.6.tgz#845338edecad65ebffef058d3be851f1d28a63bc" @@ -495,6 +514,15 @@ "@babel/parser" "^7.16.7" "@babel/types" "^7.16.7" +"@babel/template@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" + integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/parser" "^7.18.10" + "@babel/types" "^7.18.10" + "@babel/template@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" @@ -529,6 +557,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.10.tgz#37ad97d1cb00efa869b91dd5d1950f8a6cf0cb08" + integrity sha512-J7ycxg0/K9XCtLyHf0cz2DqDihonJeIo+z+HEdRe9YuT8TY4A66i+Ab2/xZCEW7Ro60bPCBBfqqboHSamoV3+g== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.18.10" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.18.9" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.18.10" + "@babel/types" "^7.18.10" + debug "^4.1.0" + globals "^11.1.0" + "@babel/traverse@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98" @@ -553,6 +597,15 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@babel/types@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" + integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== + dependencies: + "@babel/helper-string-parser" "^7.18.10" + "@babel/helper-validator-identifier" "^7.18.6" + to-fast-properties "^2.0.0" + "@babel/types@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.6.tgz#5d781dd10a3f0c9f1f931bd19de5eb26ec31acf0" @@ -613,15 +666,20 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@humanwhocodes/config-array@^0.9.2": - version "0.9.2" - resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.2.tgz" - integrity sha512-UXOuFCGcwciWckOpmfKDq/GyhlTf9pN/BzG//x8p8zTOFEcGuA68ANXheFS0AGvy3qgZqLBUkMs7hqzqCKOVwA== +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz" @@ -1139,10 +1197,10 @@ resolved "https://registry.yarnpkg.com/@types/memoizee/-/memoizee-0.4.8.tgz#04adc0c266a0f5d72db0556fdda2ba17dad9b519" integrity sha512-qDpXKGgwKywnQt/64fH1O0LiPA++QGIYeykEUiZ51HymKVRLnUSGcRuF60IfpPeeXiuRwiR/W4y7S5VzbrgLCA== -"@types/mime@2.0.3": - version "2.0.3" - resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz" - integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q== +"@types/mime@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" + integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== "@types/mime@^1": version "1.3.2" @@ -1167,10 +1225,10 @@ resolved "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz" integrity sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw== -"@types/nodemailer@6.4.4": - version "6.4.4" - resolved "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz" - integrity sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw== +"@types/nodemailer@6.4.5": + version "6.4.5" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.5.tgz#09011ac73259245475d1688e4ba101860567dc39" + integrity sha512-zuP3nBRQHI6M2PkXnGGy1Ww4VB+MyYHGgnfV2T+JR9KLkeWqPJuyVUgLpKXuFnA/b7pZaIDFh2sV4759B7jK1g== dependencies: "@types/node" "*" @@ -1204,10 +1262,10 @@ resolved "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz" integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== -"@types/semver@7.3.10": - version "7.3.10" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.10.tgz#5f19ee40cbeff87d916eedc8c2bfe2305d957f73" - integrity sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw== +"@types/semver@7.3.12": + version "7.3.12" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c" + integrity sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A== "@types/serve-static@*": version "1.13.10" @@ -1275,14 +1333,14 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.0.tgz#524a11e15c09701733033c96943ecf33f55d9ca1" - integrity sha512-lvhRJ2pGe2V9MEU46ELTdiHgiAFZPKtLhiU5wlnaYpMc2+c1R8fh8i80ZAa665drvjHKUJyRRGg3gEm1If54ow== +"@typescript-eslint/eslint-plugin@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.0.tgz#059798888720ec52ffa96c5f868e31a8f70fa3ec" + integrity sha512-jHvZNSW2WZ31OPJ3enhLrEKvAZNyAFWZ6rx9tUwaessTc4sx9KmgMNhVcqVAl1ETnT5rU5fpXTLmY9YvC1DCNg== dependencies: - "@typescript-eslint/scope-manager" "5.30.0" - "@typescript-eslint/type-utils" "5.30.0" - "@typescript-eslint/utils" "5.30.0" + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/type-utils" "5.33.0" + "@typescript-eslint/utils" "5.33.0" debug "^4.3.4" functional-red-black-tree "^1.0.1" ignore "^5.2.0" @@ -1290,69 +1348,69 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.30.0.tgz#a2184fb5f8ef2bf1db0ae61a43907e2e32aa1b8f" - integrity sha512-2oYYUws5o2liX6SrFQ5RB88+PuRymaM2EU02/9Ppoyu70vllPnHVO7ioxDdq/ypXHA277R04SVjxvwI8HmZpzA== +"@typescript-eslint/parser@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.33.0.tgz#26ec3235b74f0667414613727cb98f9b69dc5383" + integrity sha512-cgM5cJrWmrDV2KpvlcSkelTBASAs1mgqq+IUGKJvFxWrapHpaRy5EXPQz9YaKF3nZ8KY18ILTiVpUtbIac86/w== dependencies: - "@typescript-eslint/scope-manager" "5.30.0" - "@typescript-eslint/types" "5.30.0" - "@typescript-eslint/typescript-estree" "5.30.0" + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/typescript-estree" "5.33.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.30.0.tgz#bf585ee801ab4ad84db2f840174e171a6bb002c7" - integrity sha512-3TZxvlQcK5fhTBw5solQucWSJvonXf5yua5nx8OqK94hxdrT7/6W3/CS42MLd/f1BmlmmbGEgQcTHHCktUX5bQ== +"@typescript-eslint/scope-manager@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.0.tgz#509d7fa540a2c58f66bdcfcf278a3fa79002e18d" + integrity sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw== dependencies: - "@typescript-eslint/types" "5.30.0" - "@typescript-eslint/visitor-keys" "5.30.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/visitor-keys" "5.33.0" -"@typescript-eslint/type-utils@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.30.0.tgz#98f3af926a5099153f092d4dad87148df21fbaae" - integrity sha512-GF8JZbZqSS+azehzlv/lmQQ3EU3VfWYzCczdZjJRxSEeXDQkqFhCBgFhallLDbPwQOEQ4MHpiPfkjKk7zlmeNg== +"@typescript-eslint/type-utils@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.33.0.tgz#92ad1fba973c078d23767ce2d8d5a601baaa9338" + integrity sha512-2zB8uEn7hEH2pBeyk3NpzX1p3lF9dKrEbnXq1F7YkpZ6hlyqb2yZujqgRGqXgRBTHWIUG3NGx/WeZk224UKlIA== dependencies: - "@typescript-eslint/utils" "5.30.0" + "@typescript-eslint/utils" "5.33.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.30.0.tgz#db7d81d585a3da3801432a9c1d2fafbff125e110" - integrity sha512-vfqcBrsRNWw/LBXyncMF/KrUTYYzzygCSsVqlZ1qGu1QtGs6vMkt3US0VNSQ05grXi5Yadp3qv5XZdYLjpp8ag== +"@typescript-eslint/types@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.0.tgz#d41c584831805554b063791338b0220b613a275b" + integrity sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw== -"@typescript-eslint/typescript-estree@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.30.0.tgz#4565ee8a6d2ac368996e20b2344ea0eab1a8f0bb" - integrity sha512-hDEawogreZB4n1zoqcrrtg/wPyyiCxmhPLpZ6kmWfKF5M5G0clRLaEexpuWr31fZ42F96SlD/5xCt1bT5Qm4Nw== +"@typescript-eslint/typescript-estree@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.0.tgz#02d9c9ade6f4897c09e3508c27de53ad6bfa54cf" + integrity sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ== dependencies: - "@typescript-eslint/types" "5.30.0" - "@typescript-eslint/visitor-keys" "5.30.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/visitor-keys" "5.33.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.30.0.tgz#1dac771fead5eab40d31860716de219356f5f754" - integrity sha512-0bIgOgZflLKIcZsWvfklsaQTM3ZUbmtH0rJ1hKyV3raoUYyeZwcjQ8ZUJTzS7KnhNcsVT1Rxs7zeeMHEhGlltw== +"@typescript-eslint/utils@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.0.tgz#46797461ce3146e21c095d79518cc0f8ec574038" + integrity sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.30.0" - "@typescript-eslint/types" "5.30.0" - "@typescript-eslint/typescript-estree" "5.30.0" + "@typescript-eslint/scope-manager" "5.33.0" + "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/typescript-estree" "5.33.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.30.0": - version "5.30.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.30.0.tgz#07721d23daca2ec4c2da7f1e660d41cd78bacac3" - integrity sha512-6WcIeRk2DQ3pHKxU1Ni0qMXJkjO/zLjBymlYBy/53qxe7yjEFSvzKLDToJjURUhSl2Fzhkl4SMXQoETauF74cw== +"@typescript-eslint/visitor-keys@5.33.0": + version "5.33.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.0.tgz#fbcbb074e460c11046e067bc3384b5d66b555484" + integrity sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw== dependencies: - "@typescript-eslint/types" "5.30.0" + "@typescript-eslint/types" "5.33.0" eslint-visitor-keys "^3.3.0" "@unleash/express-openapi@^0.2.0": @@ -1415,6 +1473,11 @@ acorn@^8.2.4, acorn@^8.4.1, acorn@^8.7.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -2660,10 +2723,10 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@0.10.61, es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: - version "0.10.61" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.61.tgz#311de37949ef86b6b0dcea894d1ffedb909d3269" - integrity sha512-yFhIqQAzu2Ca2I4SE2Au3rxVfmohU9Y7wqGR+s7+H7krk26NXhIRAZDgqd6xqjCEFUomDEA3/Bo/7fKmIkW1kA== +es5-ext@0.10.62, es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: + version "0.10.62" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" + integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== dependencies: es6-iterator "^2.0.3" es6-symbol "^3.1.3" @@ -2835,13 +2898,14 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.20.0: - version "8.20.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.20.0.tgz#048ac56aa18529967da8354a478be4ec0a2bc81b" - integrity sha512-d4ixhz5SKCa1D6SCPrivP7yYVi7nyD6A4vs6HIAul9ujBzcEmZVM3/0NN/yu5nKhmO1wjp5xQ46iRfmDGlOviA== +eslint@8.21.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.21.0.tgz#1940a68d7e0573cef6f50037addee295ff9be9ef" + integrity sha512-/XJ1+Qurf1T9G2M5IHrsjp+xrGT73RZf23xA1z5wB1ZzzEAWSZKvRwhWxTFp1rvkvCfwcvAUNAP31bhKTTGfDA== dependencies: "@eslint/eslintrc" "^1.3.0" - "@humanwhocodes/config-array" "^0.9.2" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -2851,14 +2915,17 @@ eslint@8.20.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.2" + espree "^9.3.3" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" + find-up "^5.0.0" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -2890,6 +2957,15 @@ espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" +espree@^9.3.3: + version "9.3.3" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" + integrity sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" @@ -3491,6 +3567,11 @@ graceful-fs@^4.2.4: resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gravatar-url@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/gravatar-url/-/gravatar-url-3.1.0.tgz" @@ -3777,6 +3858,11 @@ ip@^1.1.5: resolved "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= +ip@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" + integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" @@ -7128,7 +7214,7 @@ universalify@^2.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -unleash-client@^3.15.0: +unleash-client@3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.15.0.tgz#6ba4d917a0d8d628e73267ae8114d261d210a1a9" integrity sha512-pNfzJa7QWhtSMTGNhmanpgqjg3xIJK4gJgQiZdkJlUY6GPDXit8p4fGs94jC8zM/xzpa1ji9+sSx6GC9YDeCiQ== @@ -7138,10 +7224,10 @@ unleash-client@^3.15.0: murmurhash3js "^3.0.1" semver "^7.3.5" -unleash-frontend@4.14.1: - version "4.14.1" - resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.1.tgz#1199dfd3f3330902588823d1b61c5c08d63dd052" - integrity sha512-m2dD+ZMQtOS2EXmtzFoJiKb0wQuGYXT34exxZCq/BOvbQgEzF38LZ5BCkO0OkCRYY+dSr7kkn3TkVpVe6R5bjA== +unleash-frontend@4.15.0-beta.0: + version "4.15.0-beta.0" + resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.15.0-beta.0.tgz#be5df76a6ed5d12491c0eae1586e6667182a3295" + integrity sha512-BOod8Twm+uJSG1yfu6IaKeQ/dgpRJGVMX/tAmym6NwVl9XuaU7cwXvv1nPA2TKNDD67nZhGVuhDMLvRAjCaqhA== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0"