1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat(#1873/playground): Return detailed information on feature toggle evaluation (#1839)

* 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
This commit is contained in:
Thomas Heartman 2022-08-04 15:41:52 +02:00 committed by GitHub
parent b406f67fb7
commit e55ad1a21e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 3639 additions and 194 deletions

View File

@ -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",

View File

@ -57,6 +57,7 @@ export default class FeatureToggleClientStore
featureQuery?: IFeatureToggleQuery,
archived: boolean = false,
isAdmin: boolean = true,
includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]> {
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<IFeatureToggleClient[]> {
return this.getAll(featureQuery, false, false);
return this.getAll(featureQuery, false, false, includeStrategyIds);
}
async getAdmin(

View File

@ -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,

View File

@ -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<typeof constraintSchema>;

View File

@ -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
>;

View File

@ -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<PlaygroundConstraintSchema> =>
fc
.tuple(fc.boolean(), strategyConstraint())
.map(([result, constraint]) => ({
...constraint,
result,
}));
const playgroundStrategyConstraints = (): Arbitrary<
PlaygroundConstraintSchema[]
> => fc.array(playgroundStrategyConstraint());
const playgroundSegment = (): Arbitrary<PlaygroundSegmentSchema> =>
fc.record({
name: fc.string({ minLength: 1 }),
id: fc.nat(),
result: fc.boolean(),
constraints: playgroundStrategyConstraints(),
});
const playgroundStrategy = (
name: string,
parameters: Arbitrary<Record<string, string>>,
): Arbitrary<PlaygroundStrategySchema> =>
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<PlaygroundStrategySchema[]> =>
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<PlaygroundFeatureSchema> =>
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<PlaygroundFeatureSchema> =>
: undefined;
activeVariant = {
enabled: isEnabled,
enabled: true,
name: targetVariant.name,
payload: targetPayload,
};
@ -51,6 +178,10 @@ export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
return {
...feature,
isEnabled,
strategies: {
result: strategyResult(),
data: feature.strategies,
},
variants: generatedVariants,
variant: activeVariant,
};

View File

@ -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<

View File

@ -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,
},
},

View File

@ -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,

View File

@ -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
>;

View File

@ -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
>;

View File

@ -55,7 +55,7 @@ export default class PlaygroundController extends Controller {
req: Request<any, any, PlaygroundRequestSchema>,
res: Response<PlaygroundResponseSchema>,
): Promise<void> {
const response: PlaygroundResponseSchema = {
const response = {
input: req.body,
features: await this.playgroundService.evaluateQuery(
req.body.projects,

View File

@ -533,8 +533,9 @@ class FeatureToggleService {
async getClientFeatures(
query?: IFeatureToggleQuery,
includeIds?: boolean,
): Promise<FeatureConfigurationClient[]> {
return this.featureToggleClientStore.getClient(query);
return this.featureToggleClientStore.getClient(query, includeIds);
}
/**

View File

@ -89,6 +89,7 @@ export const createServices = (
const clientSpecService = new ClientSpecService(config);
const playgroundService = new PlaygroundService(config, {
featureToggleServiceV2,
segmentService,
});
return {

View File

@ -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<IUnleashServices, 'featureToggleServiceV2'>,
segmentService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'segmentService'>,
) {
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<PlaygroundFeatureSchema[]> {
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;

View File

@ -3,6 +3,7 @@ import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
export interface IFeatureToggleClientStore {
getClient(
featureQuery: Partial<IFeatureToggleQuery>,
includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]>;
// @Deprecated

View File

@ -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,
};
}
}

View File

@ -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<Operator, OperatorImpl>();
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);

View File

@ -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;
}

View File

@ -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<ClientFeaturesResponse>;
}
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<void> {
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();
}
}

View File

@ -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[];
}

View File

@ -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, '_');
}

View File

@ -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 };

View File

@ -0,0 +1,39 @@
import { ClientFeaturesResponse, FeatureInterface } from '../feature';
import { Segment } from '../strategy/strategy';
export interface BootstrapProvider {
readBootstrap(): Promise<ClientFeaturesResponse | undefined>;
}
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<ClientFeaturesResponse | undefined> {
if (this.data) {
return {
version: 2,
segments: this.segments,
features: [...this.data],
};
}
return undefined;
}
}
export function resolveBootstrapProvider(
options: BootstrapOptions,
): BootstrapProvider {
return new DefaultBootstrapProvider(options);
}

View File

@ -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<void>;
}
export interface RepositoryOptions {
appName: string;
bootstrapProvider: BootstrapProvider;
storageProvider: StorageProvider<ClientFeaturesResponse>;
}
interface FeatureToggleData {
[key: string]: FeatureInterface;
}
export default class Repository {
private timer: NodeJS.Timer | undefined;
private appName: string;
private bootstrapProvider: BootstrapProvider;
private storageProvider: StorageProvider<ClientFeaturesResponse>;
private data: FeatureToggleData = {};
private segments: Map<number, Segment>;
constructor({
appName,
bootstrapProvider,
storageProvider,
}: RepositoryOptions) {
this.appName = appName;
this.bootstrapProvider = bootstrapProvider;
this.storageProvider = storageProvider;
this.segments = new Map();
}
start(): Promise<void> {
return this.loadBootstrap();
}
createSegmentLookup(segments: Segment[] | undefined): Map<number, Segment> {
if (!segments) {
return new Map();
}
return new Map(segments.map((segment) => [segment.id, segment]));
}
async save(response: ClientFeaturesResponse): Promise<void> {
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<void> {
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]);
}
}

View File

@ -0,0 +1,14 @@
import { StorageProvider } from './storage-provider';
export default class InMemStorageProvider<T> implements StorageProvider<T> {
private store: Map<string, T> = new Map<string, T>();
async set(key: string, data: T): Promise<void> {
this.store.set(key, data);
return Promise.resolve();
}
async get(key: string): Promise<T | undefined> {
return Promise.resolve(this.store.get(key));
}
}

View File

@ -0,0 +1,60 @@
import { join } from 'path';
import { promises } from 'fs';
import { safeName } from '../helpers';
const { writeFile, readFile } = promises;
export interface StorageProvider<T> {
set(key: string, data: T): Promise<void>;
get(key: string): Promise<T | undefined>;
}
export interface StorageOptions {
backupPath: string;
}
export class FileStorageProvider<T> implements StorageProvider<T> {
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<void> {
return writeFile(this.getPath(key), JSON.stringify(data));
}
async get(key: string): Promise<T | undefined> {
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;
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
import { Strategy } from './strategy';
export default class DefaultStrategy extends Strategy {
constructor() {
super('default');
}
isEnabled(): boolean {
return true;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<Strategy> = [
new DefaultStrategy(),
new ApplicationHostnameStrategy(),
new GradualRolloutRandomStrategy(),
new GradualRolloutUserIdStrategy(),
new GradualRolloutSessionIdStrategy(),
new UserWithIdStrategy(),
new RemoteAddressStrategy(),
new FlexibleRolloutStrategy(),
new UnknownStrategy(),
];

View File

@ -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;
},
);
}
}

View File

@ -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<Constraint>,
): { 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<Constraint>,
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,
};
}
}

View File

@ -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<Constraint>,
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,
};
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<UnleashClientNode> => {
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),
);
});
});

View File

@ -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, ...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<FeatureConfigurationClient>,
context: SdkContextSchema,
logError: (message: any, ...args: any[]) => void,
): Promise<UnleashClient> => {
const client = new UnleashClient({
export const mapSegmentsForBootstrap = (segments: ISegment[]): Segment[] =>
serializeDates(segments) as Segment[];
export type ClientInitOptions = {
features: NonEmptyList<FeatureConfigurationClient>;
segments?: ISegment[];
context: SdkContextSchema;
logError: (message: any, ...args: any[]) => void;
};
export const offlineUnleashClient = async ({
features,
context,
segments,
}: ClientInitOptions): Promise<FeatureEvaluator> => {
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;
};

View File

@ -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<string> =>
fc
@ -28,32 +29,55 @@ export const commonISOTimestamp = (): Arbitrary<string> =>
})
.map((timestamp) => timestamp.toISOString());
export const strategyConstraint = (): Arbitrary<ConstraintSchema> =>
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<ConstraintSchema[]> =>
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<Record<string, string>>,
parameters?: Arbitrary<Record<string, string>>,
): Arbitrary<FeatureStrategySchema> =>
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<SegmentSchema> =>
fc.record({
name: fc.constant(name),
parameters,
id: fc.integer({ min: 1 }),
name: urlFriendlyString(),
constraints: strategyConstraints(),
});
export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
fc.array(
fc.uniqueArray(
fc.oneof(
strategy('default', fc.constant({})),
strategy('default'),
strategy(
'flexibleRollout',
fc.record({
@ -89,7 +113,16 @@ export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
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<IVariant> =>
@ -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', () =>

View File

@ -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'];

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View File

@ -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"