From e55ad1a21eaaaefce1bcce3c18bcc13d5191e811 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Thu, 4 Aug 2022 15:41:52 +0200 Subject: [PATCH] feat(#1873/playground): Return detailed information on feature toggle evaluation (#1839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: return reasons why a feature evaluated to true or false Note: this is very rough and just straight ripped from the nodejs client. It will need a lot of work, but is a good place to start * Feat: add suggested shape for new payload * Chore: minor cleanup * Wip: make server compile again * Remove unused schema ref * Export new schemas * Chore: fix some tests to use sub property * Fix: fix some tests * Refactor: rename some variables, uncomment some stuff * Add segments type to bootstrap options * Add segments capability to offline feature evaluator * Fix function calls after turning params into an option abject * Feat: test strategy order, etc * Feat: add test to check that all strats are returned correctly * Feat: allow you to include strategy ids in clients * Wip: hook up segments in the offline client. Note: compared to regular clients, they still fail * Feat: add segments validation * Fix: fix test case invariant. * Chore: revert to returning only `boolean` from strategies. This _should_ make it work with custom strategies too 🤞 * Feat: make more properties of the returned feature required * Wip: add some comments and unfinished tests for edge cases * Feat: add `isEnabledInCurrentEnvironment` prop * Feat: consider more strategy failure cases * Feat: test that isenabledinenvironment matches expectations * Feat: add unknown strategies * Fix: fix property access typo * Feat: add unknown strategy for fallback purposes * Feat: test edge case: all unknown strategies * Feat: add custom strategy to arbitrary * Feat: test that features can be true, even if not enabled in env * Chore: add some comments * Wip: fix sdk tests * Remove comments, improve test logging * Feat: add descriptions and examples to playground feature schema * Switch `examples` for `example` * Update schemas with descriptions and examples * Fix: update snapshot * Fix: openapi example * Fix: merge issues * Fix: fix issue where feature evaluation state was wrong * Chore: update openapi spec * Fix: fix broken offline client tests * Refactor: move schemas into separate files * Refactor: remove "reason" for incomplete evaluation. The only instances where evaluation is incomplete is when we don't know what the strategy is. * Refactor: move unleash node client into test and dev dependencies * Wip: further removal of stuff * Chore: remove a bunch of code that we don't use * Chore: remove comment * Chore: remove unused code * Fix: fix some prettier errors * Type parameters in strategies to avoid `any` * Fix: remove commented out code * Feat: make `id` required on playground strategies * Chore: remove redundant type * Fix: remove redundant if and fix fallback evaluation * Refactor: reduce nesting and remove duplication * Fix: remove unused helper function * Refactor: type `parameters` as `unknown` * Chore: remove redundant comment * Refactor: move constraint code into a separate file * Refactor: rename `unleash` -> `feature-evaluator` * Rename class `Unleash` -> `FeatureEvaluator` * Refactor: remove this.ready and sync logic from feature evaluator * Refactor: remove unused code, rename config type * Refactor: remove event emission from the Unleash client * Remove unlistened-for events in feature evaluator * Refactor: make offline client synchronous; remove code * Fix: update openapi snapshot after adding required strategy ids * Feat: change `strategies` format. This commit changes the format of a playground feature's `strategies` properties from a list of strategies to an object with properties `result` and `data`. It looks a bit like this: ```ts type Strategies = { result: boolean | "unknown", data: Strategy[] } ``` The reason is that this allows us to avoid the breaking change that was previously suggested in the PR: `feature.isEnabled` used to be a straight boolean. Then, when we found out we couldn't necessarily evaluate all strategies (custom strats are hard!) we changed it to `boolean | 'unevaluated'`. However, this is confusing on a few levels as the playground results are no longer the same as the SDK would be, nor are they strictly boolean anymore. This change reverts the `isEnabled` functionality to what it was before (so it's always a mirror of what the SDK would show). The equivalent of `feature.isEnabled === 'unevaluated'` now becomes `feature.isEnabled && strategy.result === 'unknown'`. * Fix: Fold long string descriptions over multiple lines. * Fix: update snapshot after adding line breaks to descriptions --- package.json | 6 +- src/lib/db/feature-toggle-client-store.ts | 6 +- src/lib/openapi/index.ts | 6 + src/lib/openapi/spec/constraint-schema.ts | 27 +- .../spec/playground-constraint-schema.ts | 20 + .../spec/playground-feature-schema.test.ts | 139 +- .../openapi/spec/playground-feature-schema.ts | 109 +- .../openapi/spec/playground-request-schema.ts | 7 +- .../spec/playground-response-schema.ts | 16 +- .../openapi/spec/playground-segment-schema.ts | 38 + .../spec/playground-strategy-schema.ts | 113 ++ src/lib/routes/admin-api/playground.ts | 2 +- src/lib/services/feature-toggle-service.ts | 3 +- src/lib/services/index.ts | 1 + src/lib/services/playground-service.ts | 85 +- .../stores/feature-toggle-client-store.ts | 1 + src/lib/util/feature-evaluator/client.ts | 215 ++++ src/lib/util/feature-evaluator/constraint.ts | 154 +++ src/lib/util/feature-evaluator/context.ts | 14 + .../feature-evaluator/feature-evaluator.ts | 125 ++ src/lib/util/feature-evaluator/feature.ts | 22 + src/lib/util/feature-evaluator/helpers.ts | 40 + src/lib/util/feature-evaluator/index.ts | 10 + .../repository/bootstrap-provider.ts | 39 + .../feature-evaluator/repository/index.ts | 114 ++ .../repository/storage-provider-in-mem.ts | 14 + .../repository/storage-provider.ts | 60 + .../strategy/application-hostname-strategy.ts | 26 + .../strategy/default-strategy.ts | 11 + .../strategy/flexible-rollout-strategy.ts | 60 + .../strategy/gradual-rollout-random.ts | 22 + .../strategy/gradual-rollout-session-id.ts | 26 + .../strategy/gradual-rollout-user-id.ts | 26 + .../util/feature-evaluator/strategy/index.ts | 25 + .../strategy/remote-address-strategy.ts | 32 + .../feature-evaluator/strategy/strategy.ts | 135 ++ .../strategy/unknown-strategy.ts | 39 + .../strategy/user-with-id-strategy.ts | 15 + .../util/feature-evaluator/strategy/util.ts | 9 + src/lib/util/feature-evaluator/variant.ts | 117 ++ src/lib/util/offline-unleash-client.test.ts | 275 +++- src/lib/util/offline-unleash-client.ts | 42 +- src/test/arbitraries.test.ts | 121 +- src/test/e2e/api/admin/playground.e2e.test.ts | 46 + .../__snapshots__/openapi.e2e.test.ts.snap | 269 ++++ .../e2e/services/playground-service.test.ts | 1146 ++++++++++++++++- yarn.lock | 5 + 47 files changed, 3639 insertions(+), 194 deletions(-) create mode 100644 src/lib/openapi/spec/playground-constraint-schema.ts create mode 100644 src/lib/openapi/spec/playground-segment-schema.ts create mode 100644 src/lib/openapi/spec/playground-strategy-schema.ts create mode 100644 src/lib/util/feature-evaluator/client.ts create mode 100644 src/lib/util/feature-evaluator/constraint.ts create mode 100644 src/lib/util/feature-evaluator/context.ts create mode 100644 src/lib/util/feature-evaluator/feature-evaluator.ts create mode 100644 src/lib/util/feature-evaluator/feature.ts create mode 100644 src/lib/util/feature-evaluator/helpers.ts create mode 100644 src/lib/util/feature-evaluator/index.ts create mode 100644 src/lib/util/feature-evaluator/repository/bootstrap-provider.ts create mode 100644 src/lib/util/feature-evaluator/repository/index.ts create mode 100644 src/lib/util/feature-evaluator/repository/storage-provider-in-mem.ts create mode 100644 src/lib/util/feature-evaluator/repository/storage-provider.ts create mode 100644 src/lib/util/feature-evaluator/strategy/application-hostname-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/default-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/flexible-rollout-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/gradual-rollout-random.ts create mode 100644 src/lib/util/feature-evaluator/strategy/gradual-rollout-session-id.ts create mode 100644 src/lib/util/feature-evaluator/strategy/gradual-rollout-user-id.ts create mode 100644 src/lib/util/feature-evaluator/strategy/index.ts create mode 100644 src/lib/util/feature-evaluator/strategy/remote-address-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/unknown-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/user-with-id-strategy.ts create mode 100644 src/lib/util/feature-evaluator/strategy/util.ts create mode 100644 src/lib/util/feature-evaluator/variant.ts diff --git a/package.json b/package.json index 96e381402e..343af9e296 100644 --- a/package.json +++ b/package.json @@ -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,7 +124,6 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", - "unleash-client": "^3.15.0", "unleash-frontend": "4.14.1", "uuid": "^8.3.2" }, @@ -172,7 +173,8 @@ "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", diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index 9c6e27ba99..fce06ce232 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'); @@ -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/openapi/index.ts b/src/lib/openapi/index.ts index 62e1252bbb..ffe97e1290 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -61,6 +61,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'; @@ -170,6 +173,9 @@ export const schemas = { patchSchema, permissionSchema, playgroundFeatureSchema, + playgroundStrategySchema, + playgroundConstraintSchema, + playgroundSegmentSchema, playgroundRequestSchema, playgroundResponseSchema, projectEnvironmentSchema, 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/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/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/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/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/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/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/util/feature-evaluator/client.ts b/src/lib/util/feature-evaluator/client.ts new file mode 100644 index 0000000000..a538786fc2 --- /dev/null +++ b/src/lib/util/feature-evaluator/client.ts @@ -0,0 +1,215 @@ +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 strategy = + this.getStrategy(strategySelector.name) ?? + this.getStrategy('unknown'); + + 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..1beca88188 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,164 @@ 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 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/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/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..804af6d43c 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 @@ -692,17 +692,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 +731,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", }, @@ -1807,24 +1817,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 +1953,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 +1975,8 @@ Object { "type": "string", }, "value": Object { + "description": "The payload value stringified.", + "example": "{\\"property\\": \\"value\\"}", "type": "string", }, }, @@ -1876,8 +2004,10 @@ Object { "name", "projectId", "isEnabled", + "isEnabledInCurrentEnvironment", "variant", "variants", + "strategies", ], "type": "object", }, @@ -1886,8 +2016,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 +2056,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 +2064,7 @@ Object { }, "input": Object { "$ref": "#/components/schemas/playgroundRequestSchema", + "description": "The given input used to evaluate the features.", }, }, "required": Array [ @@ -1939,6 +2073,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 { 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/yarn.lock b/yarn.lock index b42d113b3d..94283098dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3858,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"