diff --git a/package.json b/package.json index 57de8e89c2..3f097864be 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", - "unleash-client": "3.21.0", + "unleash-client": "4.1.0-beta.5", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/src/lib/features/playground/feature-evaluator/client.ts b/src/lib/features/playground/feature-evaluator/client.ts index b070bbab04..6a9d7bcc50 100644 --- a/src/lib/features/playground/feature-evaluator/client.ts +++ b/src/lib/features/playground/feature-evaluator/client.ts @@ -2,10 +2,10 @@ import { Strategy } from './strategy'; import { FeatureInterface } from './feature'; import { RepositoryInterface } from './repository'; import { - Variant, getDefaultVariant, - VariantDefinition, selectVariant, + Variant, + VariantDefinition, } from './variant'; import { Context } from './context'; import { SegmentForEvaluation } from './strategy/strategy'; @@ -24,6 +24,8 @@ export type EvaluatedPlaygroundStrategy = Omit< export type FeatureStrategiesEvaluationResult = { result: boolean | typeof playgroundStrategyEvaluation.unknownResult; + variant?: Variant; + variants?: VariantDefinition[]; strategies: EvaluatedPlaygroundStrategy[]; }; @@ -110,30 +112,45 @@ export default class UnleashClient { ?.map(this.getSegment(this.repository)) .filter(Boolean) ?? []; + const evaluationResult = strategy.isEnabledWithConstraints( + strategySelector.parameters, + context, + strategySelector.constraints, + segments, + strategySelector.disabled, + strategySelector.variants, + ); + return { name: strategySelector.name, id: strategySelector.id, title: strategySelector.title, disabled: strategySelector.disabled || false, parameters: strategySelector.parameters, - ...strategy.isEnabledWithConstraints( - strategySelector.parameters, - context, - strategySelector.constraints, - segments, - strategySelector.disabled, - ), + ...evaluationResult, }; }, ); // Feature evaluation - const overallStrategyResult = () => { + const overallStrategyResult = (): [ + boolean | typeof playgroundStrategyEvaluation.unknownResult, + VariantDefinition[] | undefined, + Variant | undefined | null, + ] => { // if at least one strategy is enabled, then the feature is enabled + const enabledStrategy = strategies.find( + (strategy) => strategy.result.enabled === true, + ); if ( - strategies.some((strategy) => strategy.result.enabled === true) + enabledStrategy && + enabledStrategy.result.evaluationStatus === 'complete' ) { - return true; + return [ + true, + enabledStrategy.result.variants, + enabledStrategy.result.variant, + ]; } // if at least one strategy is unknown, then the feature _may_ be enabled @@ -142,14 +159,21 @@ export default class UnleashClient { (strategy) => strategy.result.enabled === 'unknown', ) ) { - return playgroundStrategyEvaluation.unknownResult; + return [ + playgroundStrategyEvaluation.unknownResult, + undefined, + undefined, + ]; } - return false; + return [false, undefined, undefined]; }; + const [result, variants, variant] = overallStrategyResult(); const evalResults: FeatureStrategiesEvaluationResult = { - result: overallStrategyResult(), + result, + variant, + variants, strategies, }; @@ -197,8 +221,27 @@ export default class UnleashClient { ): Variant { const fallback = fallbackVariant || getDefaultVariant(); const feature = this.repository.getToggle(name); + + if (typeof feature === 'undefined') { + return fallback; + } + + let enabled = true; + if (checkToggle) { + const result = this.isFeatureEnabled(feature, context, () => + fallbackVariant ? fallbackVariant.enabled : false, + ); + enabled = result.result === true; + const strategyVariant = result.variant; + if (enabled && strategyVariant) { + return strategyVariant; + } + if (!enabled) { + return fallback; + } + } + if ( - typeof feature === 'undefined' || !feature.variants || !Array.isArray(feature.variants) || feature.variants.length === 0 || @@ -207,17 +250,6 @@ export default class UnleashClient { 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, diff --git a/src/lib/features/playground/feature-evaluator/strategy/strategy.ts b/src/lib/features/playground/feature-evaluator/strategy/strategy.ts index 6f61cd58b9..41855be54e 100644 --- a/src/lib/features/playground/feature-evaluator/strategy/strategy.ts +++ b/src/lib/features/playground/feature-evaluator/strategy/strategy.ts @@ -3,6 +3,7 @@ import { PlaygroundSegmentSchema } from 'lib/openapi/spec/playground-segment-sch import { StrategyEvaluationResult } from '../client'; import { Constraint, operators } from '../constraint'; import { Context } from '../context'; +import { selectVariantDefinition, VariantDefinition } from '../variant'; export type SegmentForEvaluation = { name: string; @@ -16,6 +17,7 @@ export interface StrategyTransportInterface { disabled?: boolean; parameters: any; constraints: Constraint[]; + variants?: VariantDefinition[]; segments?: number[]; id?: string; } @@ -114,11 +116,12 @@ export class Strategy { } isEnabledWithConstraints( - parameters: unknown, + parameters: Record, context: Context, constraints: Iterable, segments: Array, disabled?: boolean, + variantDefinitions?: VariantDefinition[], ): StrategyEvaluationResult { const constraintResults = this.checkConstraints(context, constraints); const enabledResult = this.isEnabled(parameters, context); @@ -127,10 +130,27 @@ export class Strategy { const overallResult = constraintResults.result && enabledResult && segmentResults.result; + const variantDefinition = variantDefinitions + ? selectVariantDefinition( + parameters.groupId as string, + variantDefinitions, + context, + ) + : undefined; + const variant = variantDefinition + ? { + name: variantDefinition.name, + enabled: true, + payload: variantDefinition.payload, + } + : undefined; + return { result: { enabled: disabled ? false : overallResult, evaluationStatus: 'complete', + variant, + variants: variant ? variantDefinitions : undefined, }, constraints: constraintResults.constraints, segments: segmentResults.segments, diff --git a/src/lib/features/playground/feature-evaluator/variant.ts b/src/lib/features/playground/feature-evaluator/variant.ts index 10bb497ac7..4448ff2635 100644 --- a/src/lib/features/playground/feature-evaluator/variant.ts +++ b/src/lib/features/playground/feature-evaluator/variant.ts @@ -3,7 +3,6 @@ import { Context } from './context'; import { FeatureInterface } from './feature'; import normalizedValue from './strategy/util'; import { resolveContextValue } from './helpers'; -import { PayloadType } from 'unleash-client'; interface Override { contextName: string; @@ -11,7 +10,7 @@ interface Override { } export interface Payload { - type: PayloadType; + type: 'string' | 'csv' | 'json'; value: string; } @@ -19,8 +18,8 @@ export interface VariantDefinition { name: string; weight: number; stickiness?: string; - payload: Payload; - overrides: Override[]; + payload?: Payload; + overrides?: Override[]; } export interface Variant { @@ -66,39 +65,40 @@ function overrideMatchesContext(context: Context): (o: Override) => boolean { } function findOverride( - feature: FeatureInterface, + variants: VariantDefinition[], context: Context, ): VariantDefinition | undefined { - return feature.variants + return variants .filter((variant) => variant.overrides) .find((variant) => - variant.overrides.some(overrideMatchesContext(context)), + variant.overrides?.some(overrideMatchesContext(context)), ); } -export function selectVariant( - feature: FeatureInterface, +export function selectVariantDefinition( + featureName: string, + variants: VariantDefinition[], context: Context, ): VariantDefinition | null { - const totalWeight = feature.variants.reduce((acc, v) => acc + v.weight, 0); + const totalWeight = variants.reduce((acc, v) => acc + v.weight, 0); if (totalWeight <= 0) { return null; } - const variantOverride = findOverride(feature, context); + const variantOverride = findOverride(variants, context); if (variantOverride) { return variantOverride; } - const { stickiness } = feature.variants[0]; + const { stickiness } = variants[0]; const target = normalizedValue( getSeed(context, stickiness), - feature.name, + featureName, totalWeight, ); let counter = 0; - const variant = feature.variants.find( + const variant = variants.find( (v: VariantDefinition): VariantDefinition | undefined => { if (v.weight === 0) { return undefined; @@ -112,3 +112,10 @@ export function selectVariant( ); return variant || null; } + +export function selectVariant( + feature: FeatureInterface, + context: Context, +): VariantDefinition | null { + return selectVariantDefinition(feature.name, feature.variants, context); +} diff --git a/src/lib/features/playground/offline-unleash-client.test.ts b/src/lib/features/playground/offline-unleash-client.test.ts index a6da34b7a4..e8cd525e5e 100644 --- a/src/lib/features/playground/offline-unleash-client.test.ts +++ b/src/lib/features/playground/offline-unleash-client.test.ts @@ -250,6 +250,77 @@ describe('offline client', () => { expect(client.isEnabled(name, {}).result).toBeTruthy(); }); + it('returns strategy variant over feature variant', async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient({ + features: [ + { + strategies: [ + { + name: 'default', + constraints: [ + { + values: ['my-app-name'], + inverted: false, + operator: 'IN' as 'IN', + contextName: 'appName', + caseInsensitive: false, + }, + ], + variants: [ + { + name: 'ignoreNonMatchingStrategyVariant', + weightType: 'variable', + weight: 1000, + stickiness: 'default', + }, + ], + }, + { + name: 'default', + constraints: [ + { + values: ['client-test'], + inverted: false, + operator: 'IN' as 'IN', + contextName: 'appName', + caseInsensitive: false, + }, + ], + variants: [ + { + name: 'strategyVariant', + weightType: 'variable', + weight: 1000, + stickiness: 'default', + }, + ], + }, + ], + project: 'default', + stale: false, + enabled: true, + name, + type: 'experiment', + variants: [ + { + name: 'ignoreFeatureStrategyVariant', + weightType: 'variable', + weight: 1000, + stickiness: 'default', + }, + ], + }, + ], + context: { appName: 'client-test' }, + logError: console.log, + }); + + expect(client.getVariant(name, {}).name).toEqual('strategyVariant'); + expect(client.getVariant(name, {}).enabled).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' }; diff --git a/src/lib/features/playground/offline-unleash-client.ts b/src/lib/features/playground/offline-unleash-client.ts index 4bf4327211..8815123319 100644 --- a/src/lib/features/playground/offline-unleash-client.ts +++ b/src/lib/features/playground/offline-unleash-client.ts @@ -28,6 +28,13 @@ export const mapFeaturesForClient = ( strategies: feature.strategies.map((strategy) => ({ parameters: {}, ...strategy, + variants: (strategy.variants || []).map((variant) => ({ + ...variant, + payload: variant.payload && { + ...variant.payload, + type: variant.payload.type as PayloadType, + }, + })), constraints: strategy.constraints && strategy.constraints.map((constraint) => ({ diff --git a/src/lib/features/playground/playground-service.ts b/src/lib/features/playground/playground-service.ts index b1e4e410d3..fcef309da1 100644 --- a/src/lib/features/playground/playground-service.ts +++ b/src/lib/features/playground/playground-service.ts @@ -180,7 +180,10 @@ export class PlaygroundService { name: feature.name, environment, context, - variants: variantsMap[feature.name] || [], + variants: + strategyEvaluationResult.variants || + variantsMap[feature.name] || + [], }; }); } diff --git a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap index a9439af3db..90169d38ac 100644 --- a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap @@ -20,6 +20,19 @@ exports[`featureSchema constraints 1`] = ` exports[`featureSchema variant override values must be an array 1`] = ` { "errors": [ + { + "instancePath": "/variants/0/payload/type", + "keyword": "enum", + "message": "must be equal to one of the allowed values", + "params": { + "allowedValues": [ + "json", + "csv", + "string", + ], + }, + "schemaPath": "#/properties/payload/properties/type/enum", + }, { "instancePath": "/variants/0/overrides/0/values", "keyword": "type", diff --git a/src/lib/openapi/spec/advanced-playground-environment-feature-schema.ts b/src/lib/openapi/spec/advanced-playground-environment-feature-schema.ts index c3ca5df9d9..a50a72012e 100644 --- a/src/lib/openapi/spec/advanced-playground-environment-feature-schema.ts +++ b/src/lib/openapi/spec/advanced-playground-environment-feature-schema.ts @@ -93,7 +93,7 @@ export const advancedPlaygroundEnvironmentFeatureSchema = { 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.`, + Otherwise, you'll get one of the feature's defined variants.`, type: 'object', additionalProperties: false, required: ['name', 'enabled'], @@ -118,7 +118,6 @@ export const advancedPlaygroundEnvironmentFeatureSchema = { type: { description: 'The format of the payload.', type: 'string', - enum: ['json', 'csv', 'string'], }, value: { type: 'string', diff --git a/src/lib/openapi/spec/client-features-schema.test.ts b/src/lib/openapi/spec/client-features-schema.test.ts index ddb8ad42f9..9bf290845e 100644 --- a/src/lib/openapi/spec/client-features-schema.test.ts +++ b/src/lib/openapi/spec/client-features-schema.test.ts @@ -23,7 +23,7 @@ test('clientFeaturesSchema required fields', () => { weightType: 'fix', stickiness: 'c', payload: { - type: 'a', + type: 'string', value: 'b', }, overrides: [ diff --git a/src/lib/openapi/spec/create-strategy-variant-schema.ts b/src/lib/openapi/spec/create-strategy-variant-schema.ts index 2a93a77847..e777317b1c 100644 --- a/src/lib/openapi/spec/create-strategy-variant-schema.ts +++ b/src/lib/openapi/spec/create-strategy-variant-schema.ts @@ -43,6 +43,7 @@ export const createStrategyVariantSchema = { description: 'The type of the value. Commonly used types are string, json and csv.', type: 'string', + enum: ['json', 'csv', 'string'], }, value: { description: 'The actual value of payload', diff --git a/src/lib/openapi/spec/feature-schema.test.ts b/src/lib/openapi/spec/feature-schema.test.ts index 191bf8d28f..845220a548 100644 --- a/src/lib/openapi/spec/feature-schema.test.ts +++ b/src/lib/openapi/spec/feature-schema.test.ts @@ -11,7 +11,7 @@ test('featureSchema', () => { weightType: 'fix', stickiness: 'a', overrides: [{ contextName: 'a', values: ['a'] }], - payload: { type: 'a', value: 'b' }, + payload: { type: 'string', value: 'b' }, }, ], environments: [ diff --git a/src/lib/openapi/spec/playground-feature-schema.ts b/src/lib/openapi/spec/playground-feature-schema.ts index 10ea359f85..67579d4fdd 100644 --- a/src/lib/openapi/spec/playground-feature-schema.ts +++ b/src/lib/openapi/spec/playground-feature-schema.ts @@ -108,7 +108,6 @@ export const playgroundFeatureSchema = { type: { description: 'The format of the payload.', type: 'string', - enum: ['json', 'csv', 'string'], }, value: { type: 'string', diff --git a/src/lib/openapi/spec/playground-strategy-schema.ts b/src/lib/openapi/spec/playground-strategy-schema.ts index d1494166a2..4de02c3cd3 100644 --- a/src/lib/openapi/spec/playground-strategy-schema.ts +++ b/src/lib/openapi/spec/playground-strategy-schema.ts @@ -2,6 +2,8 @@ import { FromSchema } from 'json-schema-to-ts'; import { parametersSchema } from './parameters-schema'; import { playgroundConstraintSchema } from './playground-constraint-schema'; import { playgroundSegmentSchema } from './playground-segment-schema'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; export const playgroundStrategyEvaluation = { evaluationComplete: 'complete', @@ -51,6 +53,55 @@ export const strategyEvaluationResults = { description: 'Whether this strategy evaluates to true or not.', }, + 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 the feature's defined variants.`, + type: 'object', + additionalProperties: false, + required: ['name', 'enabled'], + properties: { + 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', + description: + 'The payload value stringified.', + example: '{"property": "value"}', + }, + }, + }, + }, + nullable: true, + example: { name: 'green', enabled: true }, + }, + variants: { + type: 'array', + description: 'The feature variants.', + items: { $ref: variantSchema.$id }, + }, }, }, ], @@ -139,6 +190,8 @@ export const playgroundStrategySchema = { playgroundConstraintSchema, playgroundSegmentSchema, parametersSchema, + variantSchema, + overrideSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/proxy-feature-schema.ts b/src/lib/openapi/spec/proxy-feature-schema.ts index c8979b62fc..d7bd1a3782 100644 --- a/src/lib/openapi/spec/proxy-feature-schema.ts +++ b/src/lib/openapi/spec/proxy-feature-schema.ts @@ -1,5 +1,4 @@ import { FromSchema } from 'json-schema-to-ts'; -import { PayloadType } from 'unleash-client'; export const proxyFeatureSchema = { $id: '#/components/schemas/proxyFeatureSchema', @@ -51,7 +50,7 @@ export const proxyFeatureSchema = { type: { type: 'string', description: 'The format of the payload.', - enum: Object.values(PayloadType), + enum: ['json', 'csv', 'string'], }, value: { type: 'string', diff --git a/src/lib/openapi/spec/variant-schema.ts b/src/lib/openapi/spec/variant-schema.ts index 0dd3335490..c455fb3d18 100644 --- a/src/lib/openapi/spec/variant-schema.ts +++ b/src/lib/openapi/spec/variant-schema.ts @@ -38,11 +38,13 @@ export const variantSchema = { type: 'object', required: ['type', 'value'], description: 'Extra data configured for this variant', + additionalProperties: false, properties: { type: { description: 'The type of the value. Commonly used types are string, json and csv.', type: 'string', + enum: ['json', 'csv', 'string'], }, value: { description: 'The actual value of payload', diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index a7448988c2..c1b02ec0e9 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -122,7 +122,7 @@ export interface IVariant { weight: number; weightType: 'variable' | 'fix'; payload?: { - type: string; + type: 'json' | 'csv' | 'string'; value: string; }; stickiness: string; diff --git a/yarn.lock b/yarn.lock index 9e7d091dd0..a6a264dd79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7509,15 +7509,15 @@ universalify@^0.1.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unleash-client@3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.21.0.tgz#a31ab30acb42abfb3a21180aa83e4415a3124ec1" - integrity sha512-I7eYhRyOia3oBZ9Tu1v+IlNO+XJgsjcMEO2+j+e4A7LTTKZvGoV8WPfDGGxiMPKBPHNUACkERB3YhCQ9jzTGoQ== +unleash-client@4.1.0-beta.5: + version "4.1.0-beta.5" + resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-4.1.0-beta.5.tgz#7407a9dae30411cb2cb849569a6e058cf6b6c47c" + integrity sha512-aN5PdvfAlVBc7Fm5cgQr7pc2j6rvbRtp6G9kow0O3FP4h3UCFbM2i0NvSB4r3F8AysXWonbv9IB/TyAC2CGsPA== dependencies: ip "^1.1.8" make-fetch-happen "^10.2.1" murmurhash3js "^3.0.1" - semver "^7.3.8" + semver "^7.5.3" unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0"