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:
parent
b406f67fb7
commit
e55ad1a21e
@ -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",
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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>;
|
||||
|
20
src/lib/openapi/spec/playground-constraint-schema.ts
Normal file
20
src/lib/openapi/spec/playground-constraint-schema.ts
Normal 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
|
||||
>;
|
@ -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,
|
||||
};
|
||||
|
@ -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<
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
@ -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,
|
||||
|
38
src/lib/openapi/spec/playground-segment-schema.ts
Normal file
38
src/lib/openapi/spec/playground-segment-schema.ts
Normal 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
|
||||
>;
|
113
src/lib/openapi/spec/playground-strategy-schema.ts
Normal file
113
src/lib/openapi/spec/playground-strategy-schema.ts
Normal 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
|
||||
>;
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -89,6 +89,7 @@ export const createServices = (
|
||||
const clientSpecService = new ClientSpecService(config);
|
||||
const playgroundService = new PlaygroundService(config, {
|
||||
featureToggleServiceV2,
|
||||
segmentService,
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -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;
|
||||
|
@ -3,6 +3,7 @@ import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
|
||||
export interface IFeatureToggleClientStore {
|
||||
getClient(
|
||||
featureQuery: Partial<IFeatureToggleQuery>,
|
||||
includeStrategyIds?: boolean,
|
||||
): Promise<IFeatureToggleClient[]>;
|
||||
|
||||
// @Deprecated
|
||||
|
215
src/lib/util/feature-evaluator/client.ts
Normal file
215
src/lib/util/feature-evaluator/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
154
src/lib/util/feature-evaluator/constraint.ts
Normal file
154
src/lib/util/feature-evaluator/constraint.ts
Normal 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);
|
14
src/lib/util/feature-evaluator/context.ts
Normal file
14
src/lib/util/feature-evaluator/context.ts
Normal 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;
|
||||
}
|
125
src/lib/util/feature-evaluator/feature-evaluator.ts
Normal file
125
src/lib/util/feature-evaluator/feature-evaluator.ts
Normal 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();
|
||||
}
|
||||
}
|
22
src/lib/util/feature-evaluator/feature.ts
Normal file
22
src/lib/util/feature-evaluator/feature.ts
Normal 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[];
|
||||
}
|
40
src/lib/util/feature-evaluator/helpers.ts
Normal file
40
src/lib/util/feature-evaluator/helpers.ts
Normal 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, '_');
|
||||
}
|
10
src/lib/util/feature-evaluator/index.ts
Normal file
10
src/lib/util/feature-evaluator/index.ts
Normal 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 };
|
@ -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);
|
||||
}
|
114
src/lib/util/feature-evaluator/repository/index.ts
Normal file
114
src/lib/util/feature-evaluator/repository/index.ts
Normal 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]);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
11
src/lib/util/feature-evaluator/strategy/default-strategy.ts
Normal file
11
src/lib/util/feature-evaluator/strategy/default-strategy.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Strategy } from './strategy';
|
||||
|
||||
export default class DefaultStrategy extends Strategy {
|
||||
constructor() {
|
||||
super('default');
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
25
src/lib/util/feature-evaluator/strategy/index.ts
Normal file
25
src/lib/util/feature-evaluator/strategy/index.ts
Normal 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(),
|
||||
];
|
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
135
src/lib/util/feature-evaluator/strategy/strategy.ts
Normal file
135
src/lib/util/feature-evaluator/strategy/strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
39
src/lib/util/feature-evaluator/strategy/unknown-strategy.ts
Normal file
39
src/lib/util/feature-evaluator/strategy/unknown-strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
9
src/lib/util/feature-evaluator/strategy/util.ts
Normal file
9
src/lib/util/feature-evaluator/strategy/util.ts
Normal 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;
|
||||
}
|
117
src/lib/util/feature-evaluator/variant.ts
Normal file
117
src/lib/util/feature-evaluator/variant.ts
Normal 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;
|
||||
}
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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', () =>
|
||||
|
@ -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'];
|
||||
|
@ -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
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user