mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
feat: strategy variants in playground (#4281)
This commit is contained in:
parent
ce70f9f54e
commit
f1d1d7d49a
@ -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": {
|
||||
|
@ -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,
|
||||
|
@ -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<string, unknown>,
|
||||
context: Context,
|
||||
constraints: Iterable<Constraint>,
|
||||
segments: Array<SegmentForEvaluation>,
|
||||
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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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' };
|
||||
|
@ -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) => ({
|
||||
|
@ -180,7 +180,10 @@ export class PlaygroundService {
|
||||
name: feature.name,
|
||||
environment,
|
||||
context,
|
||||
variants: variantsMap[feature.name] || [],
|
||||
variants:
|
||||
strategyEvaluationResult.variants ||
|
||||
variantsMap[feature.name] ||
|
||||
[],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -23,7 +23,7 @@ test('clientFeaturesSchema required fields', () => {
|
||||
weightType: 'fix',
|
||||
stickiness: 'c',
|
||||
payload: {
|
||||
type: 'a',
|
||||
type: 'string',
|
||||
value: 'b',
|
||||
},
|
||||
overrides: [
|
||||
|
@ -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',
|
||||
|
@ -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: [
|
||||
|
@ -108,7 +108,6 @@ export const playgroundFeatureSchema = {
|
||||
type: {
|
||||
description: 'The format of the payload.',
|
||||
type: 'string',
|
||||
enum: ['json', 'csv', 'string'],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -122,7 +122,7 @@ export interface IVariant {
|
||||
weight: number;
|
||||
weightType: 'variable' | 'fix';
|
||||
payload?: {
|
||||
type: string;
|
||||
type: 'json' | 'csv' | 'string';
|
||||
value: string;
|
||||
};
|
||||
stickiness: string;
|
||||
|
10
yarn.lock
10
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"
|
||||
|
Loading…
Reference in New Issue
Block a user