From c695f159c0fbc752bd66d9b11f91286d4e0b2982 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:19:04 -0700 Subject: [PATCH 01/10] Add bucketing and common tests. --- sdk-common/src/index.ts | 6 + .../__tests__/evaluation/Bucketer.test.ts | 175 ++++++++++++++++++ .../store/InMemoryFeatureStore.test.ts | 4 +- server-sdk-common/src/evaluation/Bucketer.ts | 76 ++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 server-sdk-common/__tests__/evaluation/Bucketer.test.ts create mode 100644 server-sdk-common/src/evaluation/Bucketer.ts diff --git a/sdk-common/src/index.ts b/sdk-common/src/index.ts index 1fcd673c45..237b34681a 100644 --- a/sdk-common/src/index.ts +++ b/sdk-common/src/index.ts @@ -1,3 +1,9 @@ +import Context from './Context'; + export * from './api'; export * from './validators'; export * from './logging'; + +export { + Context, +}; diff --git a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts new file mode 100644 index 0000000000..fcb632990a --- /dev/null +++ b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts @@ -0,0 +1,175 @@ +// Because we are not providing a sha1 implementation within the SDK common +// We cannot fully validate bucketing in the common tests. Platform implementations +// should contain a consistency test. +// Testing here can only validate we are providing correct inputs to the hasing algorithm. + +import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import AttributeReference from '@launchdarkly/js-sdk-common/dist/AttributeReference'; +import Bucketer from '../../src/evaluation/Bucketer'; +import { Crypto, Hasher, Hmac } from '../../src/platform'; + +const hasher: Hasher = { + update: jest.fn(), + digest: jest.fn(() => '1234567890123456'), +}; +const crypto: Crypto = { + createHash(algorithm: string): Hasher { + expect(algorithm).toEqual('sha1'); + return hasher; + }, + createHmac(algorithm: string, key: string): Hmac { + // Not used for this test. + throw new Error(`Function not implemented.${algorithm}${key}`); + }, +}; + +describe.each<[ + context: LDContext, + key: string, + attr: string, + salt: string, + isExperiment: boolean, + kindForRollout: string | undefined, + seed: number | undefined, + expected: string, +]>([ + [ + { key: 'is-key' }, + 'flag-key', + 'key', + 'salty', + false, + undefined, + undefined, + 'flag-key.salty.is-key', + ], + // No specified kind, and user, are equivalent. + [ + { key: 'is-key' }, + 'flag-key', + 'key', + 'salty', + false, + 'user', + undefined, + 'flag-key.salty.is-key', + ], + [{ key: 'is-key', secondary: 'secondary' }, + 'flag-key', + 'key', + 'salty', + false, + undefined, + undefined, + 'flag-key.salty.is-key.secondary', + ], + [{ key: 'is-key', secondary: 'secondary' }, + 'flag-key', + 'key', + 'salty', + true, + undefined, + undefined, + 'flag-key.salty.is-key', + ], + + [{ key: 'is-key' }, + 'flag-key', + 'key', + 'salty', + false, + undefined, + 82, + '82.is-key', + ], + [{ key: 'is-key', secondary: 'secondary' }, + 'flag-key', + 'key', + 'salty', + false, + undefined, + 82, + '82.is-key.secondary', + ], + [ + { key: 'is-key', kind: 'org' }, + 'flag-key', + 'key', + 'salty', + false, + 'org', + undefined, + 'flag-key.salty.is-key', + ], + [ + { key: 'is-key', kind: 'org', integer: 17 }, + 'flag-key', + 'integer', + 'salty', + false, + 'org', + undefined, + 'flag-key.salty.17', + ], + [ + { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, + 'flag-key', + 'key', + 'salty', + false, + undefined, + undefined, + 'flag-key.salty.user-key', + ], + [ + { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, + 'flag-key', + 'key', + 'salty', + false, + 'org', + undefined, + 'flag-key.salty.org-key', + ], +])('given bucketing parameters', (context, key, attr, salt, isExperiment, kindForRollout, seed, expected) => { + it('hashes the correct string', () => { + const validatedContext = Context.FromLDContext(context); + const attrRef = new AttributeReference(attr); + + const bucketer = new Bucketer(crypto); + bucketer.bucket(validatedContext!, key, attrRef, salt, isExperiment, kindForRollout, seed); + expect(hasher.update).toHaveBeenCalledWith(expected); + expect(hasher.digest).toHaveBeenCalledWith('hex'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); + +describe.each([ + ['org', 'object'], + ['org', 'array'], + ['org', 'null'], + ['bad', 'key'], +])('when given a non string or integer reference', (attr) => { + it('buckets to 0 when given bad data', () => { + const validatedContext = Context.FromLDContext({ + key: 'context-key', + kind: 'org', + object: {}, + array: [], + null: null, + }); + const attrRef = new AttributeReference(attr); + + const bucketer = new Bucketer(crypto); + expect(bucketer.bucket(validatedContext!, 'key', attrRef, 'salty', false, 'org', undefined)).toEqual(0); + expect(hasher.update).toBeCalledTimes(0); + expect(hasher.digest).toBeCalledTimes(0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/server-sdk-common/__tests__/store/InMemoryFeatureStore.test.ts b/server-sdk-common/__tests__/store/InMemoryFeatureStore.test.ts index 648916013f..0f77700a2f 100644 --- a/server-sdk-common/__tests__/store/InMemoryFeatureStore.test.ts +++ b/server-sdk-common/__tests__/store/InMemoryFeatureStore.test.ts @@ -143,8 +143,8 @@ describe('given an initialized feature store', () => { key: 'new-feature', version: 1, }; - await featureStore.upsert({namespace: 'potato'}, newPotato); - const feature = await featureStore.get({namespace: 'potato'}, newPotato.key); + await featureStore.upsert({ namespace: 'potato' }, newPotato); + const feature = await featureStore.get({ namespace: 'potato' }, newPotato.key); expect(feature).toEqual(newPotato); }); }); diff --git a/server-sdk-common/src/evaluation/Bucketer.ts b/server-sdk-common/src/evaluation/Bucketer.ts new file mode 100644 index 0000000000..4053e42365 --- /dev/null +++ b/server-sdk-common/src/evaluation/Bucketer.ts @@ -0,0 +1,76 @@ +import { Context } from '@launchdarkly/js-sdk-common'; +import AttributeReference from '@launchdarkly/js-sdk-common/dist/AttributeReference'; +import { Crypto } from '../platform'; + +/** + * Bucketing can be done by string or integer values. The need to be converted to a string + * for the hashing process. + * @param value The value to get a bucketable value for. + * @returns The value as a string, or null if the value cannot be used for bucketing. + */ +function valueForBucketing(value: any): string | null { + if (typeof value === 'string') { + return value; + } + if (Number.isInteger(value)) { + return String(value); + } + return null; +} + +export default class Bucketer { + private crypto: Crypto; + + constructor(crypto: Crypto) { + this.crypto = crypto; + } + + private sha1Hex(value: string) { + const hash = this.crypto.createHash('sha1'); + hash.update(value); + return hash.digest('hex'); + } + + /** + * Bucket the provided context using the provided parameters. + * @param context The context to bucket. Can be a 'multi' kind context, but + * the bucketing will be by a specific contained kind. + * @param key A key to use in hasing. Typically the flag key or the segement key. + * @param attr The attribute to use for bucketing. + * @param salt A salt to use in hasing. + * @param isExperiment Indicates if this rollout is an experiment. If it is, then the secondary + * key will not be used. + * @param kindForRollout The kind to use for bucketing. + * @param seed A seed to use in hasing. + */ + bucket( + context: Context, + key: string, + attr: AttributeReference, + salt: string, + isExperiment: boolean, + kindForRollout: string = 'user', + seed?: number, + ): number { + const value = context.valueForKind(kindForRollout, attr); + const bucketableValue = valueForBucketing(value); + + // Bucketing cannot be done by the specified attribute value. + if (bucketableValue === null) { + return 0; + } + + const secondary = context.secondary(kindForRollout) ?? null; + const useSecondary = secondary !== null && !isExperiment; + + const prefix = seed ? Number(seed) : `${key}.${salt}`; + const hashKey = `${prefix}.${bucketableValue}${useSecondary ? `.${secondary}` : ''}`; + const hashVal = parseInt(this.sha1Hex(hashKey).substring(0, 15), 16); + + // This is how this has worked in previous implementations, but it is not + // ideal. + // The maximum safe integer representation in JS is 2^53 - 1. + // eslint-disable-next-line @typescript-eslint/no-loss-of-precision + return hashVal / 0xfffffffffffffff; + } +} From 30325bf4d54cafc71fb237f0fedaa89e3631ce0e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:24:28 -0700 Subject: [PATCH 02/10] Correct spelling. --- server-sdk-common/__tests__/evaluation/Bucketer.test.ts | 2 +- server-sdk-common/src/evaluation/Bucketer.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts index fcb632990a..210f29ea00 100644 --- a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts +++ b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts @@ -1,7 +1,7 @@ // Because we are not providing a sha1 implementation within the SDK common // We cannot fully validate bucketing in the common tests. Platform implementations // should contain a consistency test. -// Testing here can only validate we are providing correct inputs to the hasing algorithm. +// Testing here can only validate we are providing correct inputs to the hashing algorithm. import { Context, LDContext } from '@launchdarkly/js-sdk-common'; import AttributeReference from '@launchdarkly/js-sdk-common/dist/AttributeReference'; diff --git a/server-sdk-common/src/evaluation/Bucketer.ts b/server-sdk-common/src/evaluation/Bucketer.ts index 4053e42365..83b9f3e48e 100644 --- a/server-sdk-common/src/evaluation/Bucketer.ts +++ b/server-sdk-common/src/evaluation/Bucketer.ts @@ -35,13 +35,13 @@ export default class Bucketer { * Bucket the provided context using the provided parameters. * @param context The context to bucket. Can be a 'multi' kind context, but * the bucketing will be by a specific contained kind. - * @param key A key to use in hasing. Typically the flag key or the segement key. + * @param key A key to use in hashing. Typically the flag key or the segment key. * @param attr The attribute to use for bucketing. - * @param salt A salt to use in hasing. + * @param salt A salt to use in hashing. * @param isExperiment Indicates if this rollout is an experiment. If it is, then the secondary * key will not be used. * @param kindForRollout The kind to use for bucketing. - * @param seed A seed to use in hasing. + * @param seed A seed to use in hashing. */ bucket( context: Context, From 13cf885675dff38b3f3cdb0e32ba3e64fbacce31 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:33:45 -0700 Subject: [PATCH 03/10] Check numeric conversion step. --- server-sdk-common/__tests__/evaluation/Bucketer.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts index 210f29ea00..ec4cb6def2 100644 --- a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts +++ b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts @@ -137,7 +137,11 @@ describe.each<[ const attrRef = new AttributeReference(attr); const bucketer = new Bucketer(crypto); - bucketer.bucket(validatedContext!, key, attrRef, salt, isExperiment, kindForRollout, seed); + // The hasher always returns the same value. This just checks that it converts it to a number + // in the expected way. + expect( + bucketer.bucket(validatedContext!, key, attrRef, salt, isExperiment, kindForRollout, seed) + ).toBeCloseTo(0.07111111110140983, 5); expect(hasher.update).toHaveBeenCalledWith(expected); expect(hasher.digest).toHaveBeenCalledWith('hex'); }); From 3667ae84f7b13177ab06f88969635b8ff6ebfac2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:09:01 -0700 Subject: [PATCH 04/10] Add operators and tests. --- sdk-common/src/validators.ts | 24 ++++ .../__tests__/evaluation/Bucketer.test.ts | 2 +- .../__tests__/evaluation/Operations.test.ts | 96 +++++++++++++++ server-sdk-common/package.json | 4 +- .../src/evaluation/Operations.ts | 115 ++++++++++++++++++ 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 server-sdk-common/__tests__/evaluation/Operations.test.ts create mode 100644 server-sdk-common/src/evaluation/Operations.ts diff --git a/sdk-common/src/validators.ts b/sdk-common/src/validators.ts index f3e58b4890..1f1d686f7b 100644 --- a/sdk-common/src/validators.ts +++ b/sdk-common/src/validators.ts @@ -136,6 +136,28 @@ export class Function implements TypeValidator { } } +// Our reference SDK, Go, parses date/time strings with the time.RFC3339Nano format. +// This regex should match strings that are valid in that format, and no others. +// Acceptable: +// 2019-10-31T23:59:59Z, 2019-10-31T23:59:59.100Z, +// 2019-10-31T23:59:59-07, 2019-10-31T23:59:59-07:00, etc. +// Unacceptable: no "T", no time zone designation +const DATE_REGEX = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d\d*)?(Z|[-+]\d\d(:\d\d)?)/; + +/** + * Validate a value is a date. Values which are numbers are treated as dates and any string + * which if compliant with `time.RFC3339Nano` is a date. + */ +export class DateValidator implements TypeValidator { + is(u: unknown): boolean { + return typeof u === 'number' || (typeof u === 'string' && DATE_REGEX.test(u)); + } + + getType(): string { + return 'date'; + } +} + /** * A set of standard type validators. */ @@ -161,4 +183,6 @@ export class TypeValidators { static StringMatchingRegex(expression: RegExp): StringMatchingRegex { return new StringMatchingRegex(expression); } + + static readonly Date = new DateValidator(); } diff --git a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts index ec4cb6def2..875a6d8e12 100644 --- a/server-sdk-common/__tests__/evaluation/Bucketer.test.ts +++ b/server-sdk-common/__tests__/evaluation/Bucketer.test.ts @@ -140,7 +140,7 @@ describe.each<[ // The hasher always returns the same value. This just checks that it converts it to a number // in the expected way. expect( - bucketer.bucket(validatedContext!, key, attrRef, salt, isExperiment, kindForRollout, seed) + bucketer.bucket(validatedContext!, key, attrRef, salt, isExperiment, kindForRollout, seed), ).toBeCloseTo(0.07111111110140983, 5); expect(hasher.update).toHaveBeenCalledWith(expected); expect(hasher.digest).toHaveBeenCalledWith('hex'); diff --git a/server-sdk-common/__tests__/evaluation/Operations.test.ts b/server-sdk-common/__tests__/evaluation/Operations.test.ts new file mode 100644 index 0000000000..bd72e6996f --- /dev/null +++ b/server-sdk-common/__tests__/evaluation/Operations.test.ts @@ -0,0 +1,96 @@ +import Operators from '../../src/evaluation/Operations'; + +describe.each([ + // numeric comparisons + ['in', 99, 99, true], + ['in', 99.0001, 99.0001, true], + ['in', 99, 99.0001, false], + ['in', 99.0001, 99, false], + ['lessThan', 99, 99.0001, true], + ['lessThan', 99.0001, 99, false], + ['lessThan', 99, 99, false], + ['lessThanOrEqual', 99, 99.0001, true], + ['lessThanOrEqual', 99.0001, 99, false], + ['lessThanOrEqual', 99, 99, true], + ['greaterThan', 99.0001, 99, true], + ['greaterThan', 99, 99.0001, false], + ['greaterThan', 99, 99, false], + ['greaterThanOrEqual', 99.0001, 99, true], + ['greaterThanOrEqual', 99, 99.0001, false], + ['greaterThanOrEqual', 99, 99, true], + + // string comparisons + ['in', 'x', 'x', true], + ['in', 'x', 'xyz', false], + ['startsWith', 'xyz', 'x', true], + ['startsWith', 'x', 'xyz', false], + ['endsWith', 'xyz', 'z', true], + ['endsWith', 'z', 'xyz', false], + ['contains', 'xyz', 'y', true], + ['contains', 'y', 'xyz', false], + + // mixed strings and numbers + ['in', '99', 99, false], + ['in', 99, '99', false], + ['contains', '99', 99, false], + ['startsWith', '99', 99, false], + ['endsWith', '99', 99, false], + ['lessThanOrEqual', '99', 99, false], + ['lessThanOrEqual', 99, '99', false], + ['greaterThanOrEqual', '99', 99, false], + ['greaterThanOrEqual', 99, '99', false], + + // regex + ['matches', 'hello world', 'hello.*rld', true], + ['matches', 'hello world', 'hello.*rl', true], + ['matches', 'hello world', 'l+', true], + ['matches', 'hello world', '(world|planet)', true], + ['matches', 'hello world', 'aloha', false], + ['matches', 'hello world', '***not a regex', false], + ['matches', 'hello world', 3, false], + ['matches', 3, 'hello', false], + + // dates + ['before', 0, 1, true], + ['before', -100, 0, true], + ['before', '1970-01-01T00:00:00Z', 1000, true], + ['before', '1970-01-01T00:00:00.500Z', 1000, true], + ['before', true, 1000, false], // wrong type + ['after', '1970-01-01T00:00:02.500Z', 1000, true], + ['after', '1970-01-01 00:00:02.500Z', 1000, false], // malformed timestamp + ['before', '1970-01-01T00:00:02+01:00', 1000, true], + ['before', -1000, 1000, true], + ['after', '1970-01-01T00:00:01.001Z', 1000, true], + ['after', '1970-01-01T00:00:00-01:00', 1000, true], + + // semver + ['semVerEqual', '2.0.1', '2.0.1', true], + ['semVerEqual', '2.0.1', '02.0.1', false], // leading zeroes should be disallowed + ['semVerEqual', '2.0', '2.0.0', true], + ['semVerEqual', '2', '2.0.0', true], + ['semVerEqual', '2-rc1', '2.0.0-rc1', true], + ['semVerEqual', '2+build2', '2.0.0+build2', true], + ['semVerEqual', '2.0.0', '2.0.0+build2', true], // build metadata should be ignored in comparison + ['semVerEqual', '2.0.0', '2.0.0-rc1', false], // prerelease should not be ignored + ['semVerEqual', '2.0.0', '2.0.0+build_2', false], // enforce allowable character set in build metadata + ['semVerEqual', '2.0.0', 'v2.0.0', false], // disallow leading 'v' + ['semVerLessThan', '2.0.0', '2.0.1', true], + ['semVerLessThan', '2.0', '2.0.1', true], + ['semVerLessThan', '2.0.1', '2.0.0', false], + ['semVerLessThan', '2.0.1', '2.0', false], + ['semVerLessThan', '2.0.0-rc', '2.0.0-rc.beta', true], + ['semVerLessThan', '2.0.0-rc', '2.0.0', true], + ['semVerLessThan', '2.0.0-rc.3', '2.0.0-rc.29', true], + ['semVerLessThan', '2.0.0-rc.x29', '2.0.0-rc.x3', true], + ['semVerGreaterThan', '2.0.1', '2.0.0', true], + ['semVerGreaterThan', '2.0.1', '2.0', true], + ['semVerGreaterThan', '2.0.0', '2.0.1', false], + ['semVerGreaterThan', '2.0', '2.0.1', false], + ['semVerGreaterThan', '2.0.0-rc.1', '2.0.0-rc.0', true], + ['semVerLessThan', '2.0.1', 'xbad%ver', false], + ['semVerGreaterThan', '2.0.1', 'xbad%ver', false], +])('given operations and parameters', (operator, a, b, expected) => { + it(`Operator ${operator} with ${a} and ${b} should be ${expected}`, () => { + expect(Operators.execute(operator, a, b)).toEqual(expected); + }); +}); diff --git a/server-sdk-common/package.json b/server-sdk-common/package.json index 1d57466323..042f40be4e 100644 --- a/server-sdk-common/package.json +++ b/server-sdk-common/package.json @@ -18,10 +18,12 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "@launchdarkly/js-sdk-common" + "@launchdarkly/js-sdk-common": "@launchdarkly/js-sdk-common", + "semver": "^7.3.7" }, "devDependencies": { "@types/jest": "^27.4.1", + "@types/semver": "^7.3.9", "jest": "^27.5.1", "ts-jest": "^27.1.4", "typescript": "^4.6.3" diff --git a/server-sdk-common/src/evaluation/Operations.ts b/server-sdk-common/src/evaluation/Operations.ts new file mode 100644 index 0000000000..b18f6f24ab --- /dev/null +++ b/server-sdk-common/src/evaluation/Operations.ts @@ -0,0 +1,115 @@ +import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; +import { parse, SemVer } from 'semver'; + +const VERSION_COMPONENTS_REGEX = /^\d+(\.\d+)?(\.\d+)?/; + +function parseSemver(input: any): SemVer | null { + // A leading 'v' is not supported by the standard, but may be by the semver library. + if (TypeValidators.String.is(input) && !input.startsWith('v')) { + // If the input is able to be parsed, then return that. + const parsed = parse(input); + if (parsed) { + return parsed; + } + // If not, then we are going to make some exceptions to the format. + // Specifically semver requires 3 components, but we allow versions with + // less. For instance we allow '1' to be equivalent to '1.0.0'. + const components = VERSION_COMPONENTS_REGEX.exec(input); + if (components) { + let transformed = components[0]; + // Start after the match. + for (let i = 1; i < components.length; i += 1) { + // The regex will return a match followed by each group. + // Unmatched groups are 'undefined'. + // So we will always have 3 entries, the match and 2 groups. + // For each missing group we need to append a '.0' until we have the + // standard 3. + if (components[i] === undefined) { + transformed += '.0'; + } + } + // If the original version contains pre-release information like '-beta.1', + // then this will re-incorporate that into the string. + transformed += input.substring(components[0].length); + return parse(transformed); + } + } + return null; +} + +type OperatorFn = (a: T, b: T) => boolean; + +function semVerOperator(fn: OperatorFn): OperatorFn { + return (a: any, b: any) => { + const aVer = parseSemver(a); + const bVer = parseSemver(b); + return !!((aVer && bVer) && fn(aVer, bVer)); + }; +} + +function makeOperator( + fn: OperatorFn, + validator: TypeValidator, + converter?: (val: any) => T, +): OperatorFn { + return (a: any, b: any) => { + if (validator.is(a) && validator.is(b)) { + if (converter) { + return fn(converter(a), converter(b)); + } + return fn(a, b); + } + return false; + }; +} + +function parseDate(input: string | number): number { + // Before calling this function we know the value is a date in a number + // or as a string. + if (typeof input === 'number') { + return input; + } + return Date.parse(input); +} + +function safeRegexMatch(pattern: string, value: string) { + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } +} + +interface OperatorsInterface { + [operator: string]: OperatorFn | undefined; +} + +const operators: OperatorsInterface = { + in: (a, b) => a === b, + endsWith: makeOperator((a, b) => a.endsWith(b), TypeValidators.String), + startsWith: makeOperator((a, b) => a.startsWith(b), TypeValidators.String), + matches: makeOperator( + (value, pattern) => safeRegexMatch(pattern, value), + TypeValidators.String, + ), + contains: makeOperator((a, b) => a.indexOf(b) > -1, TypeValidators.String), + lessThan: makeOperator((a, b) => a < b, TypeValidators.Number), + lessThanOrEqual: makeOperator((a, b) => a <= b, TypeValidators.Number), + greaterThan: makeOperator((a, b) => a > b, TypeValidators.Number), + greaterThanOrEqual: makeOperator((a, b) => a >= b, TypeValidators.Number), + before: makeOperator((a, b) => a < b, TypeValidators.Date, parseDate), + after: makeOperator((a, b) => a > b, TypeValidators.Date, parseDate), + semVerEqual: semVerOperator((a, b) => a.compare(b) === 0), + semVerLessThan: semVerOperator((a, b) => a.compare(b) < 0), + semVerGreaterThan: semVerOperator((a, b) => a.compare(b) > 0), +}; + +export default class Operators { + static is(op: string): boolean { + return Object.prototype.hasOwnProperty.call(operators, op); + } + + static execute(op: string, a: any, b: any): boolean { + return operators[op]?.(a, b) ?? false; + } +} From a8ab5d941dc6490a555d7f0cc6d9c45a75dcc107 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:16:07 -0700 Subject: [PATCH 05/10] Add unrecognized operator test. --- server-sdk-common/__tests__/evaluation/Operations.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server-sdk-common/__tests__/evaluation/Operations.test.ts b/server-sdk-common/__tests__/evaluation/Operations.test.ts index bd72e6996f..44fe10fc7b 100644 --- a/server-sdk-common/__tests__/evaluation/Operations.test.ts +++ b/server-sdk-common/__tests__/evaluation/Operations.test.ts @@ -91,6 +91,12 @@ describe.each([ ['semVerGreaterThan', '2.0.1', 'xbad%ver', false], ])('given operations and parameters', (operator, a, b, expected) => { it(`Operator ${operator} with ${a} and ${b} should be ${expected}`, () => { + expect(Operators.is(operator)).toBeTruthy(); expect(Operators.execute(operator, a, b)).toEqual(expected); }); }); + +it('handles unrecognized operators', () => { + expect(Operators.is('bacon')).toBeFalsy(); + expect(Operators.execute('bacon', 1, 6)).toBeFalsy(); +}); From a28b852240b5032af5e90c7bc81b601ca5147871 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 16:14:07 -0700 Subject: [PATCH 06/10] Add interfaces for the data we use during evaluations. Start scaffolding evaluator. --- server-sdk-common/src/evaluation/Evaluator.ts | 9 +++++++ server-sdk-common/src/evaluation/Queries.ts | 13 ++++++++++ .../src/evaluation/data/Clause.ts | 23 +++++++++++++++++ .../evaluation/data/ClientSideAvailability.ts | 4 +++ server-sdk-common/src/evaluation/data/Flag.ts | 25 +++++++++++++++++++ .../src/evaluation/data/Prerequisite.ts | 4 +++ .../src/evaluation/data/Rollout.ts | 9 +++++++ server-sdk-common/src/evaluation/data/Rule.ts | 10 ++++++++ .../src/evaluation/data/Segment.ts | 15 +++++++++++ .../src/evaluation/data/SegmentRule.ts | 9 +++++++ .../src/evaluation/data/SegmentTarget.ts | 4 +++ .../src/evaluation/data/Target.ts | 5 ++++ .../src/evaluation/data/Versioned.ts | 7 ++++++ .../src/evaluation/data/WeightedVariation.ts | 5 ++++ 14 files changed, 142 insertions(+) create mode 100644 server-sdk-common/src/evaluation/Evaluator.ts create mode 100644 server-sdk-common/src/evaluation/Queries.ts create mode 100644 server-sdk-common/src/evaluation/data/Clause.ts create mode 100644 server-sdk-common/src/evaluation/data/ClientSideAvailability.ts create mode 100644 server-sdk-common/src/evaluation/data/Flag.ts create mode 100644 server-sdk-common/src/evaluation/data/Prerequisite.ts create mode 100644 server-sdk-common/src/evaluation/data/Rollout.ts create mode 100644 server-sdk-common/src/evaluation/data/Rule.ts create mode 100644 server-sdk-common/src/evaluation/data/Segment.ts create mode 100644 server-sdk-common/src/evaluation/data/SegmentRule.ts create mode 100644 server-sdk-common/src/evaluation/data/SegmentTarget.ts create mode 100644 server-sdk-common/src/evaluation/data/Target.ts create mode 100644 server-sdk-common/src/evaluation/data/Versioned.ts create mode 100644 server-sdk-common/src/evaluation/data/WeightedVariation.ts diff --git a/server-sdk-common/src/evaluation/Evaluator.ts b/server-sdk-common/src/evaluation/Evaluator.ts new file mode 100644 index 0000000000..e3cd2a7f60 --- /dev/null +++ b/server-sdk-common/src/evaluation/Evaluator.ts @@ -0,0 +1,9 @@ +import { Queries } from './Queries'; + +export default class Evaluator { + private queries: Queries; + + constructor(queries: Queries) { + this.queries = queries; + } +} diff --git a/server-sdk-common/src/evaluation/Queries.ts b/server-sdk-common/src/evaluation/Queries.ts new file mode 100644 index 0000000000..8b736194a8 --- /dev/null +++ b/server-sdk-common/src/evaluation/Queries.ts @@ -0,0 +1,13 @@ +import { BigSegmentStoreMembership } from '../api/interfaces'; +import { Flag } from './data/Flag'; +import { Segment } from './data/Segment'; + +/** + * This interface is used by the evaluator to query data it may need during + * an evaluation. + */ +export interface Queries { + getFlag(key: string): Promise + getSegment(key: string): Promise + getBigSegmentsMembership(userKey: string): Promise +} diff --git a/server-sdk-common/src/evaluation/data/Clause.ts b/server-sdk-common/src/evaluation/data/Clause.ts new file mode 100644 index 0000000000..160b0c09b8 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Clause.ts @@ -0,0 +1,23 @@ +export type Op = + 'in' + | 'startsWith' + | 'endsWith' + | 'contains' + | 'matches' + | 'lessThan' + | 'lessThanOrEqual' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'before' + | 'after' + | 'segmentMatch' + | 'semVerEqual' + | 'semVerGreaterThan' + | 'semVerLessThan'; + +export interface Clause { + attribute: string, + negate: boolean, + op: Op, + values: any[]; +} diff --git a/server-sdk-common/src/evaluation/data/ClientSideAvailability.ts b/server-sdk-common/src/evaluation/data/ClientSideAvailability.ts new file mode 100644 index 0000000000..a8083b2ddd --- /dev/null +++ b/server-sdk-common/src/evaluation/data/ClientSideAvailability.ts @@ -0,0 +1,4 @@ +export interface ClientSideAvailability { + usingMobileKey?: boolean; + usingEnvironmentId?: boolean; +} diff --git a/server-sdk-common/src/evaluation/data/Flag.ts b/server-sdk-common/src/evaluation/data/Flag.ts new file mode 100644 index 0000000000..8bb06e0c1c --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Flag.ts @@ -0,0 +1,25 @@ +import { ClientSideAvailability } from './ClientSideAvailability'; +import { Prerequisite } from './Prerequisite'; +import { Rollout } from './Rollout'; +import { Rule } from './Rule'; +import { Target } from './Target'; +import { Versioned } from './Versioned'; + +type VariationOrRollout = number | Rollout; + +export interface Flag extends Versioned { + on: boolean, + prerequisites?: Prerequisite[]; + targets?: Omit[], + contextTargets?: Target[], + rules?: Rule[], + fallthrough: VariationOrRollout, + offVariation?: number; + variations: any[]; + clientSide?: boolean; + clientSideAvailability?: ClientSideAvailability; + salt?: string; + trackEvents?: boolean; + trackEventsFallthrough?: boolean; + debugEventsUntilDate?: number +} diff --git a/server-sdk-common/src/evaluation/data/Prerequisite.ts b/server-sdk-common/src/evaluation/data/Prerequisite.ts new file mode 100644 index 0000000000..95105cdab0 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Prerequisite.ts @@ -0,0 +1,4 @@ +export interface Prerequisite { + key: string; + variation: number +} diff --git a/server-sdk-common/src/evaluation/data/Rollout.ts b/server-sdk-common/src/evaluation/data/Rollout.ts new file mode 100644 index 0000000000..86aa609617 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Rollout.ts @@ -0,0 +1,9 @@ +import { WeightedVariation } from './WeightedVariation'; + +type RolloutKind = 'rollout' | 'experiment'; +export interface Rollout { + kind?: RolloutKind, + bucketBy?: string, + variations: WeightedVariation[] + seed?: number +} diff --git a/server-sdk-common/src/evaluation/data/Rule.ts b/server-sdk-common/src/evaluation/data/Rule.ts new file mode 100644 index 0000000000..d8f1b0f293 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Rule.ts @@ -0,0 +1,10 @@ +import { Clause } from './Clause'; +import { Rollout } from './Rollout'; + +export interface Rule { + id: string; + variation?: number; + rollout?: Rollout; + trackEvents: boolean; + clauses: Clause[]; +} diff --git a/server-sdk-common/src/evaluation/data/Segment.ts b/server-sdk-common/src/evaluation/data/Segment.ts new file mode 100644 index 0000000000..3d47eea6d0 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Segment.ts @@ -0,0 +1,15 @@ +import { SegmentTarget } from './SegmentTarget'; +import { Versioned } from './Versioned'; +import { SegmentRule } from './SegmentRule'; + +export interface Segment extends Versioned { + included?: string[], + excluded?: string[], + includedContexts?: SegmentTarget[]; + excludedContexts?: SegmentTarget[]; + rules?: SegmentRule[]; + salt?: string; + unbounded?: boolean; + unboundedContextKind?: string; + generation?: number; +} diff --git a/server-sdk-common/src/evaluation/data/SegmentRule.ts b/server-sdk-common/src/evaluation/data/SegmentRule.ts new file mode 100644 index 0000000000..bbe57afe97 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/SegmentRule.ts @@ -0,0 +1,9 @@ +import { Clause } from './Clause'; + +export interface SegmentRule { + id: string; + clauses: Clause[]; + weight?: number; + bucketBy?: string; + rolloutContextKind?: string; +} diff --git a/server-sdk-common/src/evaluation/data/SegmentTarget.ts b/server-sdk-common/src/evaluation/data/SegmentTarget.ts new file mode 100644 index 0000000000..d7f63b1aa4 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/SegmentTarget.ts @@ -0,0 +1,4 @@ +export interface SegmentTarget { + contextKind: string; + values: string[]; +} diff --git a/server-sdk-common/src/evaluation/data/Target.ts b/server-sdk-common/src/evaluation/data/Target.ts new file mode 100644 index 0000000000..448a3b9615 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Target.ts @@ -0,0 +1,5 @@ +export interface Target { + contextKind?: string; + values: string[], + variation: number +} diff --git a/server-sdk-common/src/evaluation/data/Versioned.ts b/server-sdk-common/src/evaluation/data/Versioned.ts new file mode 100644 index 0000000000..c04611c238 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/Versioned.ts @@ -0,0 +1,7 @@ +/** + * Common interface for flags/segments. Versioned data we store. + */ +export interface Versioned { + key: string; + version: number; +} diff --git a/server-sdk-common/src/evaluation/data/WeightedVariation.ts b/server-sdk-common/src/evaluation/data/WeightedVariation.ts new file mode 100644 index 0000000000..7029fcd6c2 --- /dev/null +++ b/server-sdk-common/src/evaluation/data/WeightedVariation.ts @@ -0,0 +1,5 @@ +export interface WeightedVariation { + variation: number; + weight: number; + untracked?: boolean; +} From d0ed14cf01111a190d50ce54406293ecebf65726 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 1 Jun 2022 16:19:02 -0700 Subject: [PATCH 07/10] Cleanup --- server-sdk-common/src/evaluation/data/Clause.ts | 2 +- server-sdk-common/src/evaluation/data/Flag.ts | 4 ++-- .../src/evaluation/data/{Rule.ts => FlagRule.ts} | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) rename server-sdk-common/src/evaluation/data/{Rule.ts => FlagRule.ts} (63%) diff --git a/server-sdk-common/src/evaluation/data/Clause.ts b/server-sdk-common/src/evaluation/data/Clause.ts index 160b0c09b8..d64f7a6ad7 100644 --- a/server-sdk-common/src/evaluation/data/Clause.ts +++ b/server-sdk-common/src/evaluation/data/Clause.ts @@ -17,7 +17,7 @@ export type Op = export interface Clause { attribute: string, - negate: boolean, + negate?: boolean, op: Op, values: any[]; } diff --git a/server-sdk-common/src/evaluation/data/Flag.ts b/server-sdk-common/src/evaluation/data/Flag.ts index 8bb06e0c1c..5c4f3319ad 100644 --- a/server-sdk-common/src/evaluation/data/Flag.ts +++ b/server-sdk-common/src/evaluation/data/Flag.ts @@ -1,7 +1,7 @@ import { ClientSideAvailability } from './ClientSideAvailability'; import { Prerequisite } from './Prerequisite'; import { Rollout } from './Rollout'; -import { Rule } from './Rule'; +import { FlagRule } from './FlagRule'; import { Target } from './Target'; import { Versioned } from './Versioned'; @@ -12,7 +12,7 @@ export interface Flag extends Versioned { prerequisites?: Prerequisite[]; targets?: Omit[], contextTargets?: Target[], - rules?: Rule[], + rules?: FlagRule[], fallthrough: VariationOrRollout, offVariation?: number; variations: any[]; diff --git a/server-sdk-common/src/evaluation/data/Rule.ts b/server-sdk-common/src/evaluation/data/FlagRule.ts similarity index 63% rename from server-sdk-common/src/evaluation/data/Rule.ts rename to server-sdk-common/src/evaluation/data/FlagRule.ts index d8f1b0f293..5690495fe4 100644 --- a/server-sdk-common/src/evaluation/data/Rule.ts +++ b/server-sdk-common/src/evaluation/data/FlagRule.ts @@ -1,10 +1,10 @@ import { Clause } from './Clause'; import { Rollout } from './Rollout'; -export interface Rule { +export interface FlagRule { id: string; variation?: number; rollout?: Rollout; - trackEvents: boolean; - clauses: Clause[]; + trackEvents?: boolean; + clauses?: Clause[]; } From 1bfb4dd983cf0317d96bd298c2921fe778de7e67 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 2 Jun 2022 16:03:44 -0700 Subject: [PATCH 08/10] Add target evaluation. --- sdk-common/__tests__/Context.test.ts | 16 ++--- sdk-common/src/Context.ts | 25 ++++++-- server-sdk-common/src/evaluation/Bucketer.ts | 2 +- .../src/evaluation/ErrorKinds.ts | 7 +++ .../src/evaluation/EvalResult.ts | 32 ++++++++++ server-sdk-common/src/evaluation/Evaluator.ts | 58 +++++++++++++++++++ server-sdk-common/src/evaluation/Reasons.ts | 17 ++++++ .../src/evaluation/collection.ts | 46 +++++++++++++++ .../src/evaluation/evalTargets.ts | 39 +++++++++++++ .../src/evaluation/variations.ts | 19 ++++++ 10 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 server-sdk-common/src/evaluation/ErrorKinds.ts create mode 100644 server-sdk-common/src/evaluation/EvalResult.ts create mode 100644 server-sdk-common/src/evaluation/Reasons.ts create mode 100644 server-sdk-common/src/evaluation/collection.ts create mode 100644 server-sdk-common/src/evaluation/evalTargets.ts create mode 100644 server-sdk-common/src/evaluation/variations.ts diff --git a/sdk-common/__tests__/Context.test.ts b/sdk-common/__tests__/Context.test.ts index 2942b9f578..aacd6ca24e 100644 --- a/sdk-common/__tests__/Context.test.ts +++ b/sdk-common/__tests__/Context.test.ts @@ -67,13 +67,13 @@ describe.each([ }); it('should get the same values', () => { - expect(context?.valueForKind('user', new AttributeReference('cat'))).toEqual('calico'); - expect(context?.valueForKind('user', new AttributeReference('name'))).toEqual('context name'); + expect(context?.valueForKind(new AttributeReference('cat'), 'user')).toEqual('calico'); + expect(context?.valueForKind(new AttributeReference('name'), 'user')).toEqual('context name'); expect(context?.kinds).toStrictEqual(['user']); expect(context?.kindsAndKeys).toStrictEqual({ user: 'test' }); // Canonical keys for 'user' contexts are just the key. expect(context?.canonicalKey).toEqual('test'); - expect(context?.valueForKind('user', new AttributeReference('transient'))).toBeTruthy(); + expect(context?.valueForKind(new AttributeReference('transient'), 'user')).toBeTruthy(); expect(context?.secondary('user')).toEqual('secondary'); expect(context?.isMultiKind).toBeFalsy(); expect(context?.privateAttributes('user')?.[0].redactionName) @@ -81,7 +81,7 @@ describe.each([ }); it('should not get values for a context kind that does not exist', () => { - expect(context?.valueForKind('org', new AttributeReference('cat'))).toBeUndefined(); + expect(context?.valueForKind(new AttributeReference('cat'), 'org')).toBeUndefined(); }); it('should have the correct kinds', () => { @@ -108,12 +108,12 @@ describe('given a valid legacy user without custom attributes', () => { }); it('should get expected values', () => { - expect(context?.valueForKind('user', new AttributeReference('name'))).toEqual('context name'); + expect(context?.valueForKind(new AttributeReference('name'), 'user')).toEqual('context name'); expect(context?.kinds).toStrictEqual(['user']); expect(context?.kindsAndKeys).toStrictEqual({ user: 'test' }); // Canonical keys for 'user' contexts are just the key. expect(context?.canonicalKey).toEqual('test'); - expect(context?.valueForKind('user', new AttributeReference('transient'))).toBeTruthy(); + expect(context?.valueForKind(new AttributeReference('transient'), 'user')).toBeTruthy(); expect(context?.secondary('user')).toEqual('secondary'); expect(context?.isMultiKind).toBeFalsy(); expect(context?.privateAttributes('user')?.[0].redactionName) @@ -204,8 +204,8 @@ describe('given a multi-kind context', () => { }); it('should get values from the correct context', () => { - expect(context?.valueForKind('org', new AttributeReference('value'))).toEqual('OrgValue'); - expect(context?.valueForKind('user', new AttributeReference('value'))).toEqual('UserValue'); + expect(context?.valueForKind(new AttributeReference('value'), 'org')).toEqual('OrgValue'); + expect(context?.valueForKind(new AttributeReference('value'), 'user')).toEqual('UserValue'); expect(context?.secondary('org')).toEqual('value'); expect(context?.secondary('user')).toBeUndefined(); diff --git a/sdk-common/src/Context.ts b/sdk-common/src/Context.ts index 65f889b705..f87570a647 100644 --- a/sdk-common/src/Context.ts +++ b/sdk-common/src/Context.ts @@ -20,6 +20,9 @@ import { TypeValidators } from './validators'; // Validates a kind excluding check that it isn't "kind". const KindValidator = TypeValidators.StringMatchingRegex(/^(\w|\.|-)+$/); +// When no kind is specified, then this kind will be used. +const DEFAULT_KIND = 'user'; + // The API allows for calling with an `LDContext` which is // `LDUser | LDSingleKindContext | LDMultiKindContext`. When ingesting a context // first the type must be determined to allow us to put it into a consistent type. @@ -170,6 +173,8 @@ export default class Context { public readonly kind: string; + static readonly userKind: string = DEFAULT_KIND; + /** * Contexts should be created using the static factory method {@link Context.FromLDContext}. * @param kind The kind of the context. @@ -306,23 +311,35 @@ export default class Context { /** * Attempt to get a value for the given context kind using the given reference. - * @param kind The kind of the context to get the value for. * @param reference The reference to the value to get. + * @param kind The kind of the context to get the value for. * @returns a value or `undefined` if one is not found. */ - public valueForKind(kind: string, reference: AttributeReference): any | undefined { + public valueForKind( + reference: AttributeReference, + kind: string = DEFAULT_KIND, + ): any | undefined { return Context.getValueFromContext(reference, this.contextForKind(kind)); } + /** + * Attempt to get a key for the specified kind. + * @param kind The kind to get a key for. + * @returns The key for the specified kind, or undefined. + */ + public key(kind: string = DEFAULT_KIND): string | undefined { + return this.contextForKind(kind)?.key; + } + /** * Attempt to get a secondary key from a context. * @param kind The kind of the context to get the secondary key for. * @returns the secondary key, or undefined if not present or not a string. */ - public secondary(kind: string): string | undefined { + public secondary(kind: string = DEFAULT_KIND): string | undefined { const context = this.contextForKind(kind); if (defined(context?._meta?.secondary) - && TypeValidators.String.is(context?._meta?.secondary)) { + && TypeValidators.String.is(context?._meta?.secondary)) { return context?._meta?.secondary; } return undefined; diff --git a/server-sdk-common/src/evaluation/Bucketer.ts b/server-sdk-common/src/evaluation/Bucketer.ts index 83b9f3e48e..79e440f586 100644 --- a/server-sdk-common/src/evaluation/Bucketer.ts +++ b/server-sdk-common/src/evaluation/Bucketer.ts @@ -52,7 +52,7 @@ export default class Bucketer { kindForRollout: string = 'user', seed?: number, ): number { - const value = context.valueForKind(kindForRollout, attr); + const value = context.valueForKind(attr, kindForRollout); const bucketableValue = valueForBucketing(value); // Bucketing cannot be done by the specified attribute value. diff --git a/server-sdk-common/src/evaluation/ErrorKinds.ts b/server-sdk-common/src/evaluation/ErrorKinds.ts new file mode 100644 index 0000000000..4801c73659 --- /dev/null +++ b/server-sdk-common/src/evaluation/ErrorKinds.ts @@ -0,0 +1,7 @@ +enum ErrorKinds { + MalformedFlag = 'MALFORMED_FLAG', + UserNotSpecified = 'USER_NOT_SPECIFIED', + FlagNotFound = 'FLAG_NOT_FOUND', +} + +export default ErrorKinds; diff --git a/server-sdk-common/src/evaluation/EvalResult.ts b/server-sdk-common/src/evaluation/EvalResult.ts new file mode 100644 index 0000000000..cef0849746 --- /dev/null +++ b/server-sdk-common/src/evaluation/EvalResult.ts @@ -0,0 +1,32 @@ +import { LDEvaluationDetail, LDEvaluationReason } from '../api'; +import ErrorKinds from './ErrorKinds'; + +export default class EvalResult { + public readonly isError: boolean; + + public readonly detail: LDEvaluationDetail; + + public readonly message?: string; + + protected constructor(isError: boolean, detail: LDEvaluationDetail, message?: string) { + this.isError = isError; + this.detail = detail; + this.message = message; + } + + static ForError(errorKind: ErrorKinds, message?: string): EvalResult { + return new EvalResult(true, { + value: null, + variationIndex: undefined, + reason: { kind: 'ERROR', errorKind }, + }, message); + } + + static ForSuccess(value: any, reason: LDEvaluationReason, variationIndex?: number) { + return new EvalResult(false, { + value, + variationIndex, + reason, + }); + } +} diff --git a/server-sdk-common/src/evaluation/Evaluator.ts b/server-sdk-common/src/evaluation/Evaluator.ts index e3cd2a7f60..da9d269871 100644 --- a/server-sdk-common/src/evaluation/Evaluator.ts +++ b/server-sdk-common/src/evaluation/Evaluator.ts @@ -1,4 +1,21 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable max-classes-per-file */ +import { Context } from '@launchdarkly/js-sdk-common'; +import { Flag } from './data/Flag'; +import EvalResult from './EvalResult'; +import { getOffVariation } from './variations'; import { Queries } from './Queries'; +import Reasons from './Reasons'; +import ErrorKinds from './ErrorKinds'; +import evalTargets from './evalTargets'; +import { FlagRule } from './data/FlagRule'; +import { Clause } from './data/Clause'; +import Operators from './Operations'; + +class EvalState { + // events + // bigSegmentsStatus +} export default class Evaluator { private queries: Queries; @@ -6,4 +23,45 @@ export default class Evaluator { constructor(queries: Queries) { this.queries = queries; } + + async evaluate(flag: Flag, context: Context): Promise { + const state = new EvalState(); + return this.evaluateInternal(flag, context, state); + } + + private async evaluateInternal( + flag: Flag, + context: Context, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + state: EvalState, + ): Promise { + if (!flag.on) { + return getOffVariation(flag, Reasons.Off); + } + + // TODO: Add prerequisite evaluation. + + const targetRes = evalTargets(flag, context); + if (targetRes) { + return targetRes; + } + + // TODO: For now this provides a default result during implementation. + return EvalResult.ForError(ErrorKinds.FlagNotFound, 'Temporary'); + } + + // private async ruleMatchContext( + // rule: FlagRule, + // context: Context, + // ): Promise { + // if (!rule.clauses) { + // return undefined; + // } + // const match = await allSeriesAsync(rule.clauses, async (clause) => { + // if (clause.op === 'segmentMatch') { + // // TODO: Implement. + // return false; + // } + // }); + // } } diff --git a/server-sdk-common/src/evaluation/Reasons.ts b/server-sdk-common/src/evaluation/Reasons.ts new file mode 100644 index 0000000000..52a165c6f4 --- /dev/null +++ b/server-sdk-common/src/evaluation/Reasons.ts @@ -0,0 +1,17 @@ +import { LDEvaluationReason } from '../api'; + +export default class Reasons { + static readonly Fallthrough: LDEvaluationReason = { kind: 'FALLTHROUGH' }; + + static readonly Off: LDEvaluationReason = { kind: 'OFF' }; + + static prerequisiteFailed(prerequisiteKey: string): LDEvaluationReason { + return { kind: 'PREREQUISITE_FAILED', prerequisiteKey }; + } + + static ruleMatch(ruleId: string): LDEvaluationReason { + return { kind: 'RULE_MATCH', ruleId }; + } + + static readonly TargetMatch: LDEvaluationReason = { kind: 'TARGET_MATCH' }; +} diff --git a/server-sdk-common/src/evaluation/collection.ts b/server-sdk-common/src/evaluation/collection.ts new file mode 100644 index 0000000000..b0a46783e9 --- /dev/null +++ b/server-sdk-common/src/evaluation/collection.ts @@ -0,0 +1,46 @@ +/** + * Iterate a collection any apply the specified operation. The first operation which + * returns a value will be returned and iteration will stop. + * + * @param collection The collection to enumerate. + * @param operator The operation to apply to each item. + * @returns The result of the first successful operation. + */ +export function firstResult( + collection: T[] | undefined, + operator: (val: T) => U | undefined, +): U | undefined { + let res; + collection?.some((item) => { + res = operator(item); + return !!res; + }); + return res; +} + +/** + * Iterate a collection in series awaiting each check operation. + * @param collection The collection to iterate. + * @param check The check to perform for each item in the container. + * @returns True if all items pass the check. + */ +export async function allSeriesAsync( + collection: T[] | undefined, + check: (val: T) => Promise, +): Promise { + if (!collection) { + return false; + } + for (let index = 0; index < collection.length; index += 1) { + // This warning is to encourage starting many operations at once. + // In this case we only want to evaluate until we encounter something that + // doesn't match. Versus starting all the evaluations and then letting them + // all resolve. + // eslint-disable-next-line no-await-in-loop + const res = await check(collection[index]); + if (!res) { + return false; + } + } + return true; +} diff --git a/server-sdk-common/src/evaluation/evalTargets.ts b/server-sdk-common/src/evaluation/evalTargets.ts new file mode 100644 index 0000000000..d8ca7a62af --- /dev/null +++ b/server-sdk-common/src/evaluation/evalTargets.ts @@ -0,0 +1,39 @@ +import { Context } from '@launchdarkly/js-sdk-common'; +import { Flag } from './data/Flag'; +import { Target } from './data/Target'; +import EvalResult from './EvalResult'; +import { getVariation } from './variations'; +import Reasons from './Reasons'; +import { firstResult } from './collection'; + +function evalTarget(flag: Flag, target: Target, context: Context): EvalResult | undefined { + const contextKey = context.key(target.contextKind); + if (contextKey !== undefined) { + const found = target.values.indexOf(contextKey) >= 0; + if (found) { + return getVariation(flag, target.variation, Reasons.TargetMatch); + } + } + + return undefined; +} + +export default function evalTargets(flag: Flag, context: Context): EvalResult | undefined { + if (!flag.contextTargets?.length) { + // There are not context targets, so we are going to evaluate the user targets. + return firstResult(flag.targets, (target) => evalTarget(flag, target, context)); + } + + return firstResult(flag.contextTargets, (target) => { + if (!target.contextKind || target.contextKind === Context.userKind) { + // When a context target is for a user, then use a user target with the same variation. + const userTarget = (flag.targets || []).find((ut) => ut.variation === target.variation); + if (userTarget) { + return evalTarget(flag, userTarget, context); + } + return undefined; + } + + return evalTarget(flag, target, context); + }); +} diff --git a/server-sdk-common/src/evaluation/variations.ts b/server-sdk-common/src/evaluation/variations.ts new file mode 100644 index 0000000000..5be7164b16 --- /dev/null +++ b/server-sdk-common/src/evaluation/variations.ts @@ -0,0 +1,19 @@ +import { TypeValidators } from '@launchdarkly/js-sdk-common'; +import { LDEvaluationReason } from '../api'; +import { Flag } from './data/Flag'; +import ErrorKinds from './ErrorKinds'; +import EvalResult from './EvalResult'; + +export function getVariation(flag: Flag, index: number, reason: LDEvaluationReason): EvalResult { + if (TypeValidators.Number.is(index) && index >= 0 && index <= flag.variations.length) { + return EvalResult.ForSuccess(flag.variations[index], reason, index); + } + return EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag'); +} + +export function getOffVariation(flag: Flag, reason: LDEvaluationReason): EvalResult { + if (!TypeValidators.Number.is(flag.offVariation)) { + return EvalResult.ForSuccess(null, reason); + } + return getVariation(flag, flag.offVariation, reason); +} From 8d08641e0c5ed82e05be8aa6f2975b5985d981a3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 3 Jun 2022 10:54:01 -0700 Subject: [PATCH 09/10] Add documentation and tests. --- .../__tests__/evaluation/Evaluator.test.ts | 115 ++++++++++++++++++ .../__tests__/evaluation/evalTargets.test.ts | 111 +++++++++++++++++ .../__tests__/evaluation/variations.test.ts | 44 +++++++ .../src/evaluation/ErrorKinds.ts | 5 + .../src/evaluation/EvalResult.ts | 6 + server-sdk-common/src/evaluation/Evaluator.ts | 3 + .../src/evaluation/Operations.ts | 5 + server-sdk-common/src/evaluation/Queries.ts | 2 + server-sdk-common/src/evaluation/Reasons.ts | 5 + server-sdk-common/src/evaluation/data/Flag.ts | 2 +- .../src/evaluation/evalTargets.ts | 8 ++ .../src/evaluation/variations.ts | 23 +++- 12 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 server-sdk-common/__tests__/evaluation/Evaluator.test.ts create mode 100644 server-sdk-common/__tests__/evaluation/evalTargets.test.ts create mode 100644 server-sdk-common/__tests__/evaluation/variations.test.ts diff --git a/server-sdk-common/__tests__/evaluation/Evaluator.test.ts b/server-sdk-common/__tests__/evaluation/Evaluator.test.ts new file mode 100644 index 0000000000..a229d02cfd --- /dev/null +++ b/server-sdk-common/__tests__/evaluation/Evaluator.test.ts @@ -0,0 +1,115 @@ +import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { BigSegmentStoreMembership } from '../../src/api/interfaces'; +import { Flag } from '../../src/evaluation/data/Flag'; +import { Segment } from '../../src/evaluation/data/Segment'; +import EvalResult from '../../src/evaluation/EvalResult'; +import Evaluator from '../../src/evaluation/Evaluator'; +import { Queries } from '../../src/evaluation/Queries'; +import Reasons from '../../src/evaluation/Reasons'; + +const offBaseFlag = { + key: 'feature0', version: 1, on: false, fallthrough: { variation: 1 }, variations: [ + 'zero', + 'one', + 'two', + ] +}; + +const noQueries: Queries = { + getFlag: function (key: string): Promise { + throw new Error('Function not implemented.'); + }, + getSegment: function (key: string): Promise { + throw new Error('Function not implemented.'); + }, + getBigSegmentsMembership: function (userKey: string): Promise { + throw new Error('Function not implemented.'); + } +}; + +describe.each<[Flag, LDContext, EvalResult | undefined]>([ + [{ + ...offBaseFlag, + }, { key: 'user-key' }, EvalResult.ForSuccess(null, Reasons.Off, undefined)], + [{ + ...offBaseFlag, offVariation: 2 + }, { key: 'user-key' }, EvalResult.ForSuccess("two", Reasons.Off, 2)], +])('Given off flags and an evaluator', (flag, context, expected) => { + const evaluator = new Evaluator(noQueries) + + // @ts-ignore + it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, async () => { + const result = await evaluator.evaluate(flag, Context.FromLDContext(context)!); + expect(result?.isError).toEqual(expected?.isError); + expect(result?.detail).toStrictEqual(expected?.detail); + expect(result?.message).toEqual(expected?.message); + }); +}); + +const targetBaseFlag = { + key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ + 'zero', + 'one', + 'two', + ] +}; + +describe.each<[Flag, LDContext, EvalResult | undefined]>([ + [{ + ...targetBaseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }] + }, { key: 'user-key' }, EvalResult.ForSuccess('zero', Reasons.TargetMatch, 0)], + [{ + ...targetBaseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ] + }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], + [{ + ...targetBaseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ], + contextTargets: [{ + values: [], + variation: 2 + }] + }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], + [{ + ...targetBaseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ], + contextTargets: [{ + contextKind: 'org', + values: ['org-key'], + variation: 1 + }] + }, { kind: 'org', key: 'org-key' }, EvalResult.ForSuccess('one', Reasons.TargetMatch, 1)], +])('given flag configurations with different targets that match', (flag, context, expected) => { + const evaluator = new Evaluator(noQueries) + // @ts-ignore + it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, async () => { + const result = await evaluator.evaluate(flag, Context.FromLDContext(context)!); + expect(result?.isError).toEqual(expected?.isError); + expect(result?.detail).toStrictEqual(expected?.detail); + expect(result?.message).toEqual(expected?.message); + }); +}); \ No newline at end of file diff --git a/server-sdk-common/__tests__/evaluation/evalTargets.test.ts b/server-sdk-common/__tests__/evaluation/evalTargets.test.ts new file mode 100644 index 0000000000..2624ed7bb7 --- /dev/null +++ b/server-sdk-common/__tests__/evaluation/evalTargets.test.ts @@ -0,0 +1,111 @@ +import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { Flag } from '../../src/evaluation/data/Flag'; +import ErrorKinds from '../../src/evaluation/ErrorKinds'; +import EvalResult from '../../src/evaluation/EvalResult'; +import evalTargets from '../../src/evaluation/evalTargets'; +import Reasons from '../../src/evaluation/Reasons'; +import { getOffVariation, getVariation } from '../../src/evaluation/variations'; + +const baseFlag = { + key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ + 'zero', + 'one', + 'two', + ] +}; + +describe.each<[Flag, LDContext, EvalResult | undefined]>([ + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }] + }, { key: 'user-key' }, EvalResult.ForSuccess('zero', Reasons.TargetMatch, 0)], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }] + }, { key: 'different-key' }, undefined], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ] + }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }] + }, { key: 'different-key' }, undefined], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ], + contextTargets: [{ + values: [], + variation: 2 + }] + }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ], + contextTargets: [{ + values: [], + variation: 2 + }] + }, { kind: 'org', key: 'user-key2' }, undefined], + [{ + ...baseFlag, targets: [{ + values: ['user-key'], + variation: 0 + }, + { + values: ['user-key2'], + variation: 2 + } + ], + contextTargets: [{ + contextKind: 'org', + values: ['org-key'], + variation: 1 + }] + }, { kind: 'org', key: 'org-key' }, EvalResult.ForSuccess('one', Reasons.TargetMatch, 1)], + [{ + ...baseFlag, + contextTargets: [{ + values: ['org-key'], + variation: 1 + }] + }, { key: 'user-key' }, undefined], + [{ + ...baseFlag, + }, { key: 'user-key' }, undefined], +])('given flag configurations with different targets', (flag, context, expected) => { + // @ts-ignore + it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, () => { + const result = evalTargets(flag, Context.FromLDContext(context)!) + expect(result?.isError).toEqual(expected?.isError); + expect(result?.detail).toStrictEqual(expected?.detail); + expect(result?.message).toEqual(expected?.message); + }); +}); \ No newline at end of file diff --git a/server-sdk-common/__tests__/evaluation/variations.test.ts b/server-sdk-common/__tests__/evaluation/variations.test.ts new file mode 100644 index 0000000000..e5c4ec00d9 --- /dev/null +++ b/server-sdk-common/__tests__/evaluation/variations.test.ts @@ -0,0 +1,44 @@ +import { Flag } from '../../src/evaluation/data/Flag'; +import ErrorKinds from '../../src/evaluation/ErrorKinds'; +import EvalResult from '../../src/evaluation/EvalResult'; +import Reasons from '../../src/evaluation/Reasons'; +import { getOffVariation, getVariation } from '../../src/evaluation/variations'; + +const baseFlag = {key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ + "zero", + "one", + "two" +]}; + +const givenReason = Reasons.TargetMatch; + +describe.each<[Flag, any, EvalResult]>([ + [{ ...baseFlag }, 0, EvalResult.ForSuccess("zero", givenReason, 0)], + [{ ...baseFlag }, 1, EvalResult.ForSuccess("one", givenReason, 1)], + [{ ...baseFlag }, 2, EvalResult.ForSuccess("two", givenReason, 2)], + [{ ...baseFlag }, 3, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], + [{ ...baseFlag }, "potato", EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], + [{ ...baseFlag }, undefined, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], +])('given flag configurations with variations', (flag, index, expected) => { + it(`produces the expected evaluation result for variations: ${flag.variations} variation index: ${index}`, () => { + const result = getVariation(flag, index as number, givenReason); + expect(result.isError).toEqual(expected.isError); + expect(result.detail).toStrictEqual(expected.detail); + expect(result.message).toEqual(expected.message); + }); +}); + +describe.each<[Flag, EvalResult]>([ + [{ ...baseFlag, offVariation: 0 }, EvalResult.ForSuccess("zero", Reasons.Off, 0)], + [{ ...baseFlag, offVariation: 1 }, EvalResult.ForSuccess("one", Reasons.Off, 1)], + [{ ...baseFlag, offVariation: 2 }, EvalResult.ForSuccess("two", Reasons.Off, 2)], + [{ ...baseFlag }, EvalResult.ForSuccess(null, Reasons.Off, undefined)], + [{ ...baseFlag, offVariation: 3 }, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], + ])('given flag configurations for accessing off variations', (flag, expected) => { + it(`produces the expected evaluation result for flag off variation: ${flag.offVariation}`, () => { + const result = getOffVariation(flag, Reasons.Off); + expect(result.isError).toEqual(expected.isError); + expect(result.detail).toStrictEqual(expected.detail); + expect(result.message).toEqual(expected.message); + }); + }); \ No newline at end of file diff --git a/server-sdk-common/src/evaluation/ErrorKinds.ts b/server-sdk-common/src/evaluation/ErrorKinds.ts index 4801c73659..3917bc8670 100644 --- a/server-sdk-common/src/evaluation/ErrorKinds.ts +++ b/server-sdk-common/src/evaluation/ErrorKinds.ts @@ -1,3 +1,8 @@ +/** + * Different kinds of error which may be encountered during evaluation. + * + * @internal + */ enum ErrorKinds { MalformedFlag = 'MALFORMED_FLAG', UserNotSpecified = 'USER_NOT_SPECIFIED', diff --git a/server-sdk-common/src/evaluation/EvalResult.ts b/server-sdk-common/src/evaluation/EvalResult.ts index cef0849746..e9a22402d2 100644 --- a/server-sdk-common/src/evaluation/EvalResult.ts +++ b/server-sdk-common/src/evaluation/EvalResult.ts @@ -1,6 +1,12 @@ import { LDEvaluationDetail, LDEvaluationReason } from '../api'; import ErrorKinds from './ErrorKinds'; +/** + * A class which encapsulates the result of an evaluation. It allows for differentiating between + * successful and error result types. + * + * @internal + */ export default class EvalResult { public readonly isError: boolean; diff --git a/server-sdk-common/src/evaluation/Evaluator.ts b/server-sdk-common/src/evaluation/Evaluator.ts index b3c4650cde..2a6ca63a38 100644 --- a/server-sdk-common/src/evaluation/Evaluator.ts +++ b/server-sdk-common/src/evaluation/Evaluator.ts @@ -14,6 +14,9 @@ class EvalState { // bigSegmentsStatus } +/** + * @internal + */ export default class Evaluator { private queries: Queries; diff --git a/server-sdk-common/src/evaluation/Operations.ts b/server-sdk-common/src/evaluation/Operations.ts index b18f6f24ab..cc8d5a3a2b 100644 --- a/server-sdk-common/src/evaluation/Operations.ts +++ b/server-sdk-common/src/evaluation/Operations.ts @@ -104,6 +104,11 @@ const operators: OperatorsInterface = { semVerGreaterThan: semVerOperator((a, b) => a.compare(b) > 0), }; +/** + * Allows checking if a specific operator is defined and allows execution of an operator on data. + * + * @internal + */ export default class Operators { static is(op: string): boolean { return Object.prototype.hasOwnProperty.call(operators, op); diff --git a/server-sdk-common/src/evaluation/Queries.ts b/server-sdk-common/src/evaluation/Queries.ts index 8b736194a8..55882f8982 100644 --- a/server-sdk-common/src/evaluation/Queries.ts +++ b/server-sdk-common/src/evaluation/Queries.ts @@ -5,6 +5,8 @@ import { Segment } from './data/Segment'; /** * This interface is used by the evaluator to query data it may need during * an evaluation. + * + * @internal */ export interface Queries { getFlag(key: string): Promise diff --git a/server-sdk-common/src/evaluation/Reasons.ts b/server-sdk-common/src/evaluation/Reasons.ts index 52a165c6f4..279c6e2280 100644 --- a/server-sdk-common/src/evaluation/Reasons.ts +++ b/server-sdk-common/src/evaluation/Reasons.ts @@ -1,5 +1,10 @@ import { LDEvaluationReason } from '../api'; +/** + * A set of static evaluation reasons and methods for creating specific reason instances. + * + * @internal + */ export default class Reasons { static readonly Fallthrough: LDEvaluationReason = { kind: 'FALLTHROUGH' }; diff --git a/server-sdk-common/src/evaluation/data/Flag.ts b/server-sdk-common/src/evaluation/data/Flag.ts index 5c4f3319ad..9ff06a158f 100644 --- a/server-sdk-common/src/evaluation/data/Flag.ts +++ b/server-sdk-common/src/evaluation/data/Flag.ts @@ -5,7 +5,7 @@ import { FlagRule } from './FlagRule'; import { Target } from './Target'; import { Versioned } from './Versioned'; -type VariationOrRollout = number | Rollout; +type VariationOrRollout = { variation: number; } | { rollout: Rollout }; export interface Flag extends Versioned { on: boolean, diff --git a/server-sdk-common/src/evaluation/evalTargets.ts b/server-sdk-common/src/evaluation/evalTargets.ts index d8ca7a62af..5c2ec0a9b5 100644 --- a/server-sdk-common/src/evaluation/evalTargets.ts +++ b/server-sdk-common/src/evaluation/evalTargets.ts @@ -18,6 +18,14 @@ function evalTarget(flag: Flag, target: Target, context: Context): EvalResult | return undefined; } +/** + * Evaluate the targets of the specified flag against the given context. + * @param flag The flag to evaluate targets for. + * @param context The context to evaluate those targets against. + * @returns An evaluation result if there is a target match/error or undefined if there is not. + * + * @internal + */ export default function evalTargets(flag: Flag, context: Context): EvalResult | undefined { if (!flag.contextTargets?.length) { // There are not context targets, so we are going to evaluate the user targets. diff --git a/server-sdk-common/src/evaluation/variations.ts b/server-sdk-common/src/evaluation/variations.ts index 5be7164b16..fe2ae9b01a 100644 --- a/server-sdk-common/src/evaluation/variations.ts +++ b/server-sdk-common/src/evaluation/variations.ts @@ -4,13 +4,34 @@ import { Flag } from './data/Flag'; import ErrorKinds from './ErrorKinds'; import EvalResult from './EvalResult'; +/** + * Attempt to get an evaluation result for the specific variation/flag combination. + * @param flag The flag to get a variation from. + * @param index The index of the flag. + * @param reason The initial evaluation reason. If there is a valid variation, then this reason + * will be returned in the EvalResult. + * @returns An evaluation result containing the successful evaluation, or an error if there is + * a problem accessing the variation. + * + * @internal + */ export function getVariation(flag: Flag, index: number, reason: LDEvaluationReason): EvalResult { - if (TypeValidators.Number.is(index) && index >= 0 && index <= flag.variations.length) { + if (TypeValidators.Number.is(index) && index >= 0 && index < flag.variations.length) { return EvalResult.ForSuccess(flag.variations[index], reason, index); } return EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag'); } +/** + * Attempt to get an off result for the specified flag. + * @param flag The flag to get the off variation for. + * @param reason The initial reason for the evaluation result. + * @returns A successful evaluation result, or an error result if there is a problem accessing + * the off variation. Flags which do not have an off variation specified will get a `null` flag + * value with an `undefined` variation. + * + * @internal + */ export function getOffVariation(flag: Flag, reason: LDEvaluationReason): EvalResult { if (!TypeValidators.Number.is(flag.offVariation)) { return EvalResult.ForSuccess(null, reason); From 8941124eaab3ec37a4054d5591e4cc5dbc1a8102 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 3 Jun 2022 10:57:43 -0700 Subject: [PATCH 10/10] Linting. --- .../__tests__/evaluation/Evaluator.test.ts | 78 +++++++++------- .../__tests__/evaluation/evalTargets.test.ts | 89 ++++++++++--------- .../__tests__/evaluation/variations.test.ts | 52 ++++++----- 3 files changed, 123 insertions(+), 96 deletions(-) diff --git a/server-sdk-common/__tests__/evaluation/Evaluator.test.ts b/server-sdk-common/__tests__/evaluation/Evaluator.test.ts index a229d02cfd..93ba72e9e4 100644 --- a/server-sdk-common/__tests__/evaluation/Evaluator.test.ts +++ b/server-sdk-common/__tests__/evaluation/Evaluator.test.ts @@ -8,23 +8,27 @@ import { Queries } from '../../src/evaluation/Queries'; import Reasons from '../../src/evaluation/Reasons'; const offBaseFlag = { - key: 'feature0', version: 1, on: false, fallthrough: { variation: 1 }, variations: [ + key: 'feature0', + version: 1, + on: false, + fallthrough: { variation: 1 }, + variations: [ 'zero', 'one', 'two', - ] + ], }; const noQueries: Queries = { - getFlag: function (key: string): Promise { + getFlag(): Promise { throw new Error('Function not implemented.'); }, - getSegment: function (key: string): Promise { + getSegment(): Promise { throw new Error('Function not implemented.'); }, - getBigSegmentsMembership: function (userKey: string): Promise { + getBigSegmentsMembership(): Promise { throw new Error('Function not implemented.'); - } + }, }; describe.each<[Flag, LDContext, EvalResult | undefined]>([ @@ -32,10 +36,10 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ ...offBaseFlag, }, { key: 'user-key' }, EvalResult.ForSuccess(null, Reasons.Off, undefined)], [{ - ...offBaseFlag, offVariation: 2 - }, { key: 'user-key' }, EvalResult.ForSuccess("two", Reasons.Off, 2)], + ...offBaseFlag, offVariation: 2, + }, { key: 'user-key' }, EvalResult.ForSuccess('two', Reasons.Off, 2)], ])('Given off flags and an evaluator', (flag, context, expected) => { - const evaluator = new Evaluator(noQueries) + const evaluator = new Evaluator(noQueries); // @ts-ignore it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, async () => { @@ -47,64 +51,72 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ }); const targetBaseFlag = { - key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ + key: 'feature0', + version: 1, + on: true, + fallthrough: { variation: 1 }, + variations: [ 'zero', 'one', 'two', - ] + ], }; describe.each<[Flag, LDContext, EvalResult | undefined]>([ [{ - ...targetBaseFlag, targets: [{ + ...targetBaseFlag, + targets: [{ values: ['user-key'], - variation: 0 - }] + variation: 0, + }], }, { key: 'user-key' }, EvalResult.ForSuccess('zero', Reasons.TargetMatch, 0)], [{ - ...targetBaseFlag, targets: [{ + ...targetBaseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } - ] + variation: 2, + }, + ], }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], [{ - ...targetBaseFlag, targets: [{ + ...targetBaseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } + variation: 2, + }, ], contextTargets: [{ values: [], - variation: 2 - }] + variation: 2, + }], }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], [{ - ...targetBaseFlag, targets: [{ + ...targetBaseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } + variation: 2, + }, ], contextTargets: [{ contextKind: 'org', values: ['org-key'], - variation: 1 - }] + variation: 1, + }], }, { kind: 'org', key: 'org-key' }, EvalResult.ForSuccess('one', Reasons.TargetMatch, 1)], ])('given flag configurations with different targets that match', (flag, context, expected) => { - const evaluator = new Evaluator(noQueries) + const evaluator = new Evaluator(noQueries); // @ts-ignore it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, async () => { const result = await evaluator.evaluate(flag, Context.FromLDContext(context)!); @@ -112,4 +124,4 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ expect(result?.detail).toStrictEqual(expected?.detail); expect(result?.message).toEqual(expected?.message); }); -}); \ No newline at end of file +}); diff --git a/server-sdk-common/__tests__/evaluation/evalTargets.test.ts b/server-sdk-common/__tests__/evaluation/evalTargets.test.ts index 2624ed7bb7..a2fc728e87 100644 --- a/server-sdk-common/__tests__/evaluation/evalTargets.test.ts +++ b/server-sdk-common/__tests__/evaluation/evalTargets.test.ts @@ -1,101 +1,110 @@ import { Context, LDContext } from '@launchdarkly/js-sdk-common'; import { Flag } from '../../src/evaluation/data/Flag'; -import ErrorKinds from '../../src/evaluation/ErrorKinds'; import EvalResult from '../../src/evaluation/EvalResult'; import evalTargets from '../../src/evaluation/evalTargets'; import Reasons from '../../src/evaluation/Reasons'; -import { getOffVariation, getVariation } from '../../src/evaluation/variations'; const baseFlag = { - key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ + key: 'feature0', + version: 1, + on: true, + fallthrough: { variation: 1 }, + variations: [ 'zero', 'one', 'two', - ] + ], }; describe.each<[Flag, LDContext, EvalResult | undefined]>([ [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 - }] + variation: 0, + }], }, { key: 'user-key' }, EvalResult.ForSuccess('zero', Reasons.TargetMatch, 0)], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 - }] + variation: 0, + }], }, { key: 'different-key' }, undefined], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } - ] + variation: 2, + }, + ], }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 - }] + variation: 0, + }], }, { key: 'different-key' }, undefined], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } + variation: 2, + }, ], contextTargets: [{ values: [], - variation: 2 - }] + variation: 2, + }], }, { key: 'user-key2' }, EvalResult.ForSuccess('two', Reasons.TargetMatch, 2)], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } + variation: 2, + }, ], contextTargets: [{ values: [], - variation: 2 - }] + variation: 2, + }], }, { kind: 'org', key: 'user-key2' }, undefined], [{ - ...baseFlag, targets: [{ + ...baseFlag, + targets: [{ values: ['user-key'], - variation: 0 + variation: 0, }, { values: ['user-key2'], - variation: 2 - } + variation: 2, + }, ], contextTargets: [{ contextKind: 'org', values: ['org-key'], - variation: 1 - }] + variation: 1, + }], }, { kind: 'org', key: 'org-key' }, EvalResult.ForSuccess('one', Reasons.TargetMatch, 1)], [{ ...baseFlag, contextTargets: [{ values: ['org-key'], - variation: 1 - }] + variation: 1, + }], }, { key: 'user-key' }, undefined], [{ ...baseFlag, @@ -103,9 +112,9 @@ describe.each<[Flag, LDContext, EvalResult | undefined]>([ ])('given flag configurations with different targets', (flag, context, expected) => { // @ts-ignore it(`produces the expected evaluation result for context: ${context.key} ${context.kind} targets: ${flag.targets?.map((t) => `${t.values}, ${t.variation}`)} context targets: ${flag.contextTargets?.map((t) => `${t.contextKind}: ${t.values}, ${t.variation}`)}`, () => { - const result = evalTargets(flag, Context.FromLDContext(context)!) + const result = evalTargets(flag, Context.FromLDContext(context)!); expect(result?.isError).toEqual(expected?.isError); expect(result?.detail).toStrictEqual(expected?.detail); expect(result?.message).toEqual(expected?.message); }); -}); \ No newline at end of file +}); diff --git a/server-sdk-common/__tests__/evaluation/variations.test.ts b/server-sdk-common/__tests__/evaluation/variations.test.ts index e5c4ec00d9..6e40bf5886 100644 --- a/server-sdk-common/__tests__/evaluation/variations.test.ts +++ b/server-sdk-common/__tests__/evaluation/variations.test.ts @@ -4,21 +4,27 @@ import EvalResult from '../../src/evaluation/EvalResult'; import Reasons from '../../src/evaluation/Reasons'; import { getOffVariation, getVariation } from '../../src/evaluation/variations'; -const baseFlag = {key: 'feature0', version: 1, on: true, fallthrough: { variation: 1 }, variations: [ - "zero", - "one", - "two" -]}; +const baseFlag = { + key: 'feature0', + version: 1, + on: true, + fallthrough: { variation: 1 }, + variations: [ + 'zero', + 'one', + 'two', + ], +}; const givenReason = Reasons.TargetMatch; describe.each<[Flag, any, EvalResult]>([ - [{ ...baseFlag }, 0, EvalResult.ForSuccess("zero", givenReason, 0)], - [{ ...baseFlag }, 1, EvalResult.ForSuccess("one", givenReason, 1)], - [{ ...baseFlag }, 2, EvalResult.ForSuccess("two", givenReason, 2)], - [{ ...baseFlag }, 3, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], - [{ ...baseFlag }, "potato", EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], - [{ ...baseFlag }, undefined, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], + [{ ...baseFlag }, 0, EvalResult.ForSuccess('zero', givenReason, 0)], + [{ ...baseFlag }, 1, EvalResult.ForSuccess('one', givenReason, 1)], + [{ ...baseFlag }, 2, EvalResult.ForSuccess('two', givenReason, 2)], + [{ ...baseFlag }, 3, EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag')], + [{ ...baseFlag }, 'potato', EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag')], + [{ ...baseFlag }, undefined, EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag')], ])('given flag configurations with variations', (flag, index, expected) => { it(`produces the expected evaluation result for variations: ${flag.variations} variation index: ${index}`, () => { const result = getVariation(flag, index as number, givenReason); @@ -29,16 +35,16 @@ describe.each<[Flag, any, EvalResult]>([ }); describe.each<[Flag, EvalResult]>([ - [{ ...baseFlag, offVariation: 0 }, EvalResult.ForSuccess("zero", Reasons.Off, 0)], - [{ ...baseFlag, offVariation: 1 }, EvalResult.ForSuccess("one", Reasons.Off, 1)], - [{ ...baseFlag, offVariation: 2 }, EvalResult.ForSuccess("two", Reasons.Off, 2)], + [{ ...baseFlag, offVariation: 0 }, EvalResult.ForSuccess('zero', Reasons.Off, 0)], + [{ ...baseFlag, offVariation: 1 }, EvalResult.ForSuccess('one', Reasons.Off, 1)], + [{ ...baseFlag, offVariation: 2 }, EvalResult.ForSuccess('two', Reasons.Off, 2)], [{ ...baseFlag }, EvalResult.ForSuccess(null, Reasons.Off, undefined)], - [{ ...baseFlag, offVariation: 3 }, EvalResult.ForError(ErrorKinds.MalformedFlag, "Invalid variation index in flag")], - ])('given flag configurations for accessing off variations', (flag, expected) => { - it(`produces the expected evaluation result for flag off variation: ${flag.offVariation}`, () => { - const result = getOffVariation(flag, Reasons.Off); - expect(result.isError).toEqual(expected.isError); - expect(result.detail).toStrictEqual(expected.detail); - expect(result.message).toEqual(expected.message); - }); - }); \ No newline at end of file + [{ ...baseFlag, offVariation: 3 }, EvalResult.ForError(ErrorKinds.MalformedFlag, 'Invalid variation index in flag')], +])('given flag configurations for accessing off variations', (flag, expected) => { + it(`produces the expected evaluation result for flag off variation: ${flag.offVariation}`, () => { + const result = getOffVariation(flag, Reasons.Off); + expect(result.isError).toEqual(expected.isError); + expect(result.detail).toStrictEqual(expected.detail); + expect(result.message).toEqual(expected.message); + }); +});