1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-09 01:17:06 +02:00
unleash.unleash/src/lib/features/playground/feature-evaluator/client.ts
2024-08-05 10:39:21 +02:00

347 lines
11 KiB
TypeScript

import type { Strategy } from './strategy';
import type { FeatureInterface } from './feature';
import type { RepositoryInterface } from './repository';
import {
getDefaultVariant,
selectVariant,
type Variant,
type VariantDefinition,
} from './variant';
import type { Context } from './context';
import type { SegmentForEvaluation } from './strategy/strategy';
import type { PlaygroundStrategySchema } from '../../../openapi/spec/playground-strategy-schema';
import { playgroundStrategyEvaluation } from '../../../openapi/spec/playground-strategy-schema';
import { randomId } from '../../../util';
export type EvaluatedPlaygroundStrategy = Omit<
PlaygroundStrategySchema,
'links'
>;
export type StrategyEvaluationResult = Pick<
EvaluatedPlaygroundStrategy,
'result' | 'segments' | 'constraints'
>;
export type FeatureStrategiesEvaluationResult = {
result: boolean | typeof playgroundStrategyEvaluation.unknownResult;
variant?: Variant;
variants?: VariantDefinition[];
strategies: EvaluatedPlaygroundStrategy[];
hasUnsatisfiedDependency?: boolean;
};
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' ||
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,
);
}
isParentDependencySatisfied(
feature: FeatureInterface | undefined,
context: Context,
) {
if (!feature?.dependencies?.length) {
return true;
}
return feature.dependencies.every((parent) => {
const parentToggle = this.repository.getToggle(parent.feature);
if (!parentToggle) {
return false;
}
if (parentToggle.dependencies?.length) {
return false;
}
if (Boolean(parent.enabled) !== Boolean(parentToggle.enabled)) {
return false;
}
if (parent.enabled !== false) {
if (parent.variants?.length) {
return parent.variants.includes(
this.getVariant(parent.feature, context).name,
);
}
return (
this.isEnabled(parent.feature, context, () => false)
.result === true
);
}
return !(
this.isEnabled(parent.feature, context, () => false).result ===
true
);
});
}
isEnabled(
name: string,
context: Context,
fallback: Function,
): FeatureStrategiesEvaluationResult {
const feature = this.repository.getToggle(name);
const parentDependencySatisfied = this.isParentDependencySatisfied(
feature,
context,
);
const result = this.isFeatureEnabled(feature, context, fallback);
return {
...result,
hasUnsatisfiedDependency: !parentDependencySatisfied,
};
}
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): EvaluatedPlaygroundStrategy => {
const getStrategy = (): Strategy => {
// assume that 'unknown' strategy is always present
const unknownStrategy = this.getStrategy(
'unknown',
) as Strategy;
// the application hostname strategy relies on external
// variables to calculate its result. As such, we can't
// evaluate it in a way that makes sense. So we'll
// use the 'unknown' strategy instead.
if (strategySelector.name === 'applicationHostname') {
return unknownStrategy;
}
return (
this.getStrategy(strategySelector.name) ??
unknownStrategy
);
};
const strategy = getStrategy();
const segments =
(strategySelector.segments
?.map(this.getSegment(this.repository))
.filter(Boolean) as SegmentForEvaluation[]) ?? [];
const evaluationResult = strategy.isEnabledWithConstraints(
strategySelector.parameters,
context,
strategySelector.constraints,
segments,
strategySelector.disabled,
strategySelector.variants,
);
return {
name: strategySelector.name,
id: strategySelector.id || randomId(),
title: strategySelector.title,
disabled: strategySelector.disabled || false,
parameters: strategySelector.parameters,
...evaluationResult,
};
},
);
// Feature evaluation
const overallStrategyResult = (): [
boolean | typeof playgroundStrategyEvaluation.unknownResult,
VariantDefinition[] | undefined,
Variant | undefined,
] => {
// if at least one strategy is enabled, then the feature is enabled
const enabledStrategy = strategies.find(
(strategy) => strategy.result.enabled === true,
);
if (
enabledStrategy &&
enabledStrategy.result.evaluationStatus === 'complete'
) {
return [
true,
enabledStrategy.result.variants,
enabledStrategy.result.variant || undefined,
];
}
// if at least one strategy is unknown, then the feature _may_ be enabled
if (
strategies.some(
(strategy) => strategy.result.enabled === 'unknown',
)
) {
return [
playgroundStrategyEvaluation.unknownResult,
undefined,
undefined,
];
}
return [false, undefined, undefined];
};
const [result, variants, variant] = overallStrategyResult();
const evalResults: FeatureStrategiesEvaluationResult = {
result,
variant,
variants,
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, 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,
forcedResult: Pick<
FeatureStrategiesEvaluationResult,
'result' | 'variant'
>,
fallbackVariant?: Variant,
): Variant {
return this.resolveVariant(
name,
context,
fallbackVariant,
forcedResult,
);
}
private resolveVariant(
name: string,
context: Context,
fallbackVariant?: Variant,
forcedResult?: Pick<
FeatureStrategiesEvaluationResult,
'result' | 'variant'
>,
): Variant {
const fallback = {
feature_enabled: false,
featureEnabled: false,
...(fallbackVariant || getDefaultVariant()),
};
const feature = this.repository.getToggle(name);
if (
typeof feature === 'undefined' ||
!this.isParentDependencySatisfied(feature, context)
) {
return fallback;
}
const result =
forcedResult ??
this.isFeatureEnabled(feature, context, () =>
fallbackVariant ? fallbackVariant.enabled : false,
);
const enabled = result.result === true;
fallback.feature_enabled = fallbackVariant?.feature_enabled ?? enabled;
fallback.featureEnabled = fallback.feature_enabled;
const strategyVariant = result.variant;
if (enabled && strategyVariant) {
return strategyVariant;
}
if (!enabled) {
return fallback;
}
if (
!feature.variants ||
!Array.isArray(feature.variants) ||
feature.variants.length === 0 ||
!feature.enabled
) {
return fallback;
}
const variant: VariantDefinition | null = selectVariant(
feature,
context,
);
if (variant === null) {
return fallback;
}
return {
name: variant.name,
payload: variant.payload,
enabled,
feature_enabled: true,
featureEnabled: true,
};
}
}