From 8562127d330be998720337b8dd11305c8b07e6ae Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Tue, 20 Aug 2024 08:35:08 -0400 Subject: [PATCH] feat: added bucketing key logic to shared-bucketing-as (#940) --- .../__tests__/bucketing/bucketing.test.ts | 191 ++++++++++++++++++ .../helpers/configWithBucketingKey.ts | 17 ++ .../__tests__/types/configBodyV2.test.ts | 47 +++-- .../assembly/bucketing/bucketing.ts | 45 ++++- .../assembly/helpers/jsonHelpers.ts | 8 + .../assembly/helpers/murmurhash.ts | 2 +- .../assembly/index.ts | 2 +- .../assembly/managers/configDataManager.ts | 2 +- .../assembly/types/configBodyV2.ts | 2 +- .../assembly/types/featureV2.ts | 66 +----- .../assembly/types/target.ts | 1 + .../assembly/types/targetV2.ts | 175 +--------------- 12 files changed, 296 insertions(+), 262 deletions(-) create mode 100644 lib/shared/bucketing-assembly-script/__tests__/helpers/configWithBucketingKey.ts diff --git a/lib/shared/bucketing-assembly-script/__tests__/bucketing/bucketing.test.ts b/lib/shared/bucketing-assembly-script/__tests__/bucketing/bucketing.test.ts index d50a50ad0..474f9d9ea 100644 --- a/lib/shared/bucketing-assembly-script/__tests__/bucketing/bucketing.test.ts +++ b/lib/shared/bucketing-assembly-script/__tests__/bucketing/bucketing.test.ts @@ -11,6 +11,7 @@ import { } from '../bucketingImportHelper' import testData from '@devcycle/bucketing-test-data/json-data/testData.json' const { config, barrenConfig, configWithNullCustomData } = testData +import { configWithBucketingKey } from '../helpers/configWithBucketingKey' import moment from 'moment' import * as uuid from 'uuid' @@ -1629,3 +1630,193 @@ describe('Client Data', () => { setClientCustomDataJSON({}) }) }) + +describe('bucketingKey tests', () => { + afterEach(() => cleanupSDK(sdkKey)) + + it('buckets a user with user_id if no bucketingKey', () => { + const user = { + user_id: 'test-id', + customData: { + favouriteFood: 'pizza', + }, + platformVersion: '1.1.2', + email: 'testwithfood@email.com', + } + const cWithBucketingKey = configWithBucketingKey('user_id') + initSDK(sdkKey, cWithBucketingKey) + + const c = generateBucketedConfig(user) + + cleanupSDK(sdkKey) + + initSDK(sdkKey, config) + const c2 = generateBucketedConfig(user) + + expect(c.features.feature5).not.toBeFalsy() + expect(c.features.feature5.variationKey).toEqual( + c2.features.feature5.variationKey, + ) + }) + + it('buckets a user with custom bucketingKey', () => { + const user = { + country: 'U S AND A', + user_id: 'pass_rollout', + customData: { + favouriteFood: 'pizza', + }, + privateCustomData: { + favouriteDrink: 'coffee', + }, + platformVersion: '1.1.2', + os: 'Android', + email: 'testwithfood@email.com', + } + const sameUserDifferentFood = { + ...user, + customData: { + favouriteFood: 'pasta', + }, + } + const differentUserSameFood = { + ...sameUserDifferentFood, + user_id: 'a_different_person', + } + const cWithBucketingKey = configWithBucketingKey('favouriteFood') + initSDK(sdkKey, cWithBucketingKey) + + const c = generateBucketedConfig(user) + const cSameUserDifferentFood = generateBucketedConfig( + sameUserDifferentFood, + ) + const cDifferentUserSameFood = generateBucketedConfig( + differentUserSameFood, + ) + + expect(c.features.feature5.variationKey).not.toEqual( + cSameUserDifferentFood.features.feature5.variationKey, + ) + expect(cSameUserDifferentFood.features.feature5.variationKey).toEqual( + cDifferentUserSameFood.features.feature5.variationKey, + ) + }) + + it('buckets a user with custom bucketingKey from privateCustomData', () => { + const user = { + country: 'U S AND A', + user_id: 'pass_rollout', + privateCustomData: { + favouriteFood: 'pizza', + }, + platformVersion: '1.1.2', + os: 'Android', + email: 'testwithfood@email.com', + } + const sameUserDifferentFood = { + ...user, + privateCustomData: { + favouriteFood: 'pasta', + }, + } + const differentUserSameFood = { + ...sameUserDifferentFood, + user_id: 'a_different_person', + } + const cWithBucketingKey = configWithBucketingKey('favouriteFood') + initSDK(sdkKey, cWithBucketingKey) + + const c = generateBucketedConfig(user) + const cSameUserDifferentFood = generateBucketedConfig( + sameUserDifferentFood, + ) + const cDifferentUserSameFood = generateBucketedConfig( + differentUserSameFood, + ) + + expect(c.features.feature5.variationKey).not.toEqual( + cSameUserDifferentFood.features.feature5.variationKey, + ) + expect(cSameUserDifferentFood.features.feature5.variationKey).toEqual( + cDifferentUserSameFood.features.feature5.variationKey, + ) + }) + + it('buckets a user with custom number bucketingKey', () => { + const user = { + country: 'U S AND A', + user_id: 'pass_rollout', + privateCustomData: { + favouriteNumber: 610, + }, + platformVersion: '1.1.2', + os: 'Android', + email: 'testwithfood@email.com', + } + const sameUserDifferentNum = { + ...user, + privateCustomData: { + favouriteNumber: 52900, + }, + } + const differentUserSameNum = { + ...sameUserDifferentNum, + user_id: 'a_different_person', + } + const cWithBucketingKey = configWithBucketingKey('favouriteNumber') + initSDK(sdkKey, cWithBucketingKey) + + const c = generateBucketedConfig(user) + const cSameUserDifferentNum = + generateBucketedConfig(sameUserDifferentNum) + const cDifferentUserSameNum = + generateBucketedConfig(differentUserSameNum) + + expect(c.features.feature5.variationKey).not.toEqual( + cSameUserDifferentNum.features.feature5.variationKey, + ) + expect(cSameUserDifferentNum.features.feature5.variationKey).toEqual( + cDifferentUserSameNum.features.feature5.variationKey, + ) + }) + + it('buckets a user with custom boolean bucketingKey', () => { + const user = { + country: 'U S AND A', + user_id: 'pass_rollout', + privateCustomData: { + signed_up: true, + }, + platformVersion: '1.1.2', + os: 'Android', + email: 'testwithfood@email.com', + } + const sameUserDifferentBool = { + ...user, + privateCustomData: { + signed_up: false, + }, + } + const differentUserSameBool = { + ...sameUserDifferentBool, + user_id: 'a_different_person', + } + const cWithBucketingKey = configWithBucketingKey('signed_up') + initSDK(sdkKey, cWithBucketingKey) + + const c = generateBucketedConfig(user) + const cSameUserDifferentBool = generateBucketedConfig( + sameUserDifferentBool, + ) + const cDifferentUserSameBool = generateBucketedConfig( + differentUserSameBool, + ) + + expect(c.features.feature5.variationKey).not.toEqual( + cSameUserDifferentBool.features.feature5.variationKey, + ) + expect(cSameUserDifferentBool.features.feature5.variationKey).toEqual( + cDifferentUserSameBool.features.feature5.variationKey, + ) + }) +}) diff --git a/lib/shared/bucketing-assembly-script/__tests__/helpers/configWithBucketingKey.ts b/lib/shared/bucketing-assembly-script/__tests__/helpers/configWithBucketingKey.ts new file mode 100644 index 000000000..b1d5ecea9 --- /dev/null +++ b/lib/shared/bucketing-assembly-script/__tests__/helpers/configWithBucketingKey.ts @@ -0,0 +1,17 @@ +import { config } from '@devcycle/bucketing-test-data/json-data/testData.json' + +export function configWithBucketingKey(bucketingKey: string): unknown { + return { + ...config, + features: config.features.map((feature) => ({ + ...feature, + configuration: { + ...feature.configuration, + targets: feature.configuration.targets.map((target) => ({ + ...target, + bucketingKey, + })), + }, + })), + } +} diff --git a/lib/shared/bucketing-assembly-script/__tests__/types/configBodyV2.test.ts b/lib/shared/bucketing-assembly-script/__tests__/types/configBodyV2.test.ts index fffba4b91..5e2ecf237 100644 --- a/lib/shared/bucketing-assembly-script/__tests__/types/configBodyV2.test.ts +++ b/lib/shared/bucketing-assembly-script/__tests__/types/configBodyV2.test.ts @@ -9,6 +9,8 @@ import cloneDeep from 'lodash/cloneDeep' import immutable from 'object-path-immutable' import { Feature } from '@devcycle/types' +import { configWithBucketingKey } from '../helpers/configWithBucketingKey' + const testConfigBodyV2 = (str: string, utf8: boolean): any => { if (utf8) { const buff = Buffer.from(str, 'utf8') @@ -40,23 +42,26 @@ const postProcessedConfig = (config: unknown) => { } describe.each([true, false])('Config Body V2', (utf8) => { + const testConfig = configWithBucketingKey( + 'user_id', + ) as typeof testData.config it('should parse valid JSON into ConfigBody class', () => { - expect(testConfigBodyV2(JSON.stringify(testData.config), utf8)).toEqual( - postProcessedConfig(testData.config), + expect(testConfigBodyV2(JSON.stringify(testConfig), utf8)).toEqual( + postProcessedConfig(testConfig), ) }) it('should parse if missing optional top level field', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore delete config.clientSDKKey expect(testConfigBodyV2(JSON.stringify(config), utf8)).toEqual( - immutable.del(postProcessedConfig(testData.config), 'clientSDKKey'), + immutable.del(postProcessedConfig(testConfig), 'clientSDKKey'), ) }) it('should throw if target.rollout is missing type', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const target: any = config.features[0].configuration.targets[0] target.rollout = { startDate: new Date(), @@ -67,7 +72,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should parse target.bucketingKey exists', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const target: any = config.features[0].configuration.targets[0] target.bucketingKey = 'bucketingKey' expect(testConfigBodyV2(JSON.stringify(config), utf8)).toEqual( @@ -76,7 +81,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should parse startsWith/endsWith filter', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -91,14 +96,18 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should handle extended UTF8 characters, from UTF8: ' + utf8, () => { - const testConfig = immutable.set(testData.config, 'project.key', '๐Ÿ‘ รถ') - expect(testConfigBodyV2(JSON.stringify(testConfig), utf8)).toEqual( - postProcessedConfig(testConfig), + const testInvalidConfig = immutable.set( + testConfig, + 'project.key', + '๐Ÿ‘ รถ', ) + expect( + testConfigBodyV2(JSON.stringify(testInvalidConfig), utf8), + ).toEqual(postProcessedConfig(testInvalidConfig)) }) it('should throw if feature.type is missing not a valid type', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const feature: any = config.features[0] feature.type = 'invalid' expect(() => testConfigBodyV2(JSON.stringify(config), utf8)).toThrow( @@ -108,7 +117,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should throw if audience is missing fields for user filter', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -121,7 +130,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should not throw if audience is using invalid operator', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.operator = 'xylophone' @@ -131,7 +140,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should throw if audience is missing comparator for user filter', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -145,7 +154,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should throw if custom data filter is missing dataKey', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -161,7 +170,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should throw if custom data filter is missing dataKeyType', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -177,7 +186,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should throw if custom data filter has invalid dataKeyType', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -194,7 +203,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { }) it('should pass if audience is missing fields but type is all', () => { - const config = cloneDeep(testData.config) + const config = cloneDeep(testConfig) const filters = config.features[0].configuration.targets[0]._audience.filters filters.filters[0] = { @@ -207,7 +216,7 @@ describe.each([true, false])('Config Body V2', (utf8) => { it('does not fail on multiple iterations of testConfigBodyClass', () => { for (let i = 0; i < 1000; i++) { - testConfigBodyV2(JSON.stringify(testData.config), utf8) + testConfigBodyV2(JSON.stringify(testConfig), utf8) } }) }) diff --git a/lib/shared/bucketing-assembly-script/assembly/bucketing/bucketing.ts b/lib/shared/bucketing-assembly-script/assembly/bucketing/bucketing.ts index 4007d2b75..557e794f7 100644 --- a/lib/shared/bucketing-assembly-script/assembly/bucketing/bucketing.ts +++ b/lib/shared/bucketing-assembly-script/assembly/bucketing/bucketing.ts @@ -1,31 +1,32 @@ import { JSON } from '@devcycle/assemblyscript-json/assembly' import { first, last } from '../helpers/lodashHelpers' import { - ConfigBody, - Target as PublicTarget, - Feature as PublicFeature, + ConfigBodyV2 as ConfigBody, + TargetV2 as PublicTarget, + FeatureV2 as PublicFeature, BucketedUserConfig, Rollout as PublicRollout, DVCPopulatedUser, SDKVariable, SDKFeature, RolloutStage, - Target, + TargetV2 as Target, Variation, FeatureVariation, - Feature, + FeatureV2 as Feature, } from '../types' import { murmurhashV3 } from '../helpers/murmurhash' import { _evaluateOperator } from './segmentation' import { - getStringFromJSON, getStringFromJSONOptional, + getValueFromJSONOptional, } from '../helpers/jsonHelpers' // Max value of an unsigned 32-bit integer, which is what murmurhash returns const MAX_HASH_VALUE: f64 = 4294967295 const baseSeed: i32 = 1 +const DEFAULT_BUCKETING_VALUE = 'null' export class BoundedHash { public rolloutHash: f64 @@ -144,7 +145,8 @@ function evaluateSegmentationForFeature( const passthroughRolloutEnabled = !config.project.settings.disablePassthroughRollouts let doesUserPassRollout = true if (target.rollout && passthroughRolloutEnabled) { - const boundedHashData = _generateBoundedHashes(user.user_id, target._id) + const bucketingValue = _getUserValueForBucketingKey(user, target) + const boundedHashData = _generateBoundedHashes(bucketingValue, target._id) const rolloutHash = boundedHashData.rolloutHash doesUserPassRollout = _doesUserPassRollout(target.rollout, rolloutHash) } @@ -211,7 +213,8 @@ function doesUserQualifyForFeature( ) if (!target) return null - const boundedHashData = _generateBoundedHashes(user.user_id, target._id) + const bucketingValue = _getUserValueForBucketingKey( user, target ) + const boundedHashData = _generateBoundedHashes(bucketingValue, target._id) const rolloutHash = boundedHashData.rolloutHash const passthroughRolloutEnabled = !config.project.settings.disablePassthroughRollouts if (target.rollout && !passthroughRolloutEnabled && !_doesUserPassRollout(target.rollout, rolloutHash)) { @@ -381,3 +384,29 @@ export function _generateBucketedVariableForUser( ) return { variable: sdkVar, variation, feature: featureForVariable } } + +export function _getUserValueForBucketingKey( + user: DVCPopulatedUser, + target: PublicTarget +): string { + if (target.bucketingKey && target.bucketingKey !== 'user_id') { + let bucketingValue: string = DEFAULT_BUCKETING_VALUE + const customData = user.getCombinedCustomData() + if (customData) { + const customDataValue = getValueFromJSONOptional(customData, target.bucketingKey) + bucketingValue = customDataValue + ? customDataValue.toString() + : DEFAULT_BUCKETING_VALUE + } + if ( + typeof bucketingValue !== 'string' && + typeof bucketingValue !== 'number' && + typeof bucketingValue !== 'boolean' + ) { + return DEFAULT_BUCKETING_VALUE + } else { + return bucketingValue.toString() + } + } + return user.user_id +} \ No newline at end of file diff --git a/lib/shared/bucketing-assembly-script/assembly/helpers/jsonHelpers.ts b/lib/shared/bucketing-assembly-script/assembly/helpers/jsonHelpers.ts index fedfcf11b..242b3eda7 100644 --- a/lib/shared/bucketing-assembly-script/assembly/helpers/jsonHelpers.ts +++ b/lib/shared/bucketing-assembly-script/assembly/helpers/jsonHelpers.ts @@ -29,6 +29,14 @@ export function getJSONArrayFromJSON(jsonObj: JSON.Obj, key: string): JSON.Arr { return obj } +export function getValueFromJSONOptional(jsonObj: JSON.Obj, key: string): JSON.Value | null { + const value = jsonObj.get(key) + if (!value) { + return null + } + return value +} + export function getStringFromJSON(jsonObj: JSON.Obj, key: string): string { const str = jsonObj.getString(key) if (!str) { diff --git a/lib/shared/bucketing-assembly-script/assembly/helpers/murmurhash.ts b/lib/shared/bucketing-assembly-script/assembly/helpers/murmurhash.ts index e9e9a8db5..ce3e12a59 100644 --- a/lib/shared/bucketing-assembly-script/assembly/helpers/murmurhash.ts +++ b/lib/shared/bucketing-assembly-script/assembly/helpers/murmurhash.ts @@ -14,7 +14,7 @@ const keyBuffer = new StaticArray(murmurhashBufferSize) export function murmurhashV3(key: string, seed: u32): u32 { let currentBuffer = keyBuffer if (key.length > keyBuffer.length) { - console.log("Warning: exceeded maximum size of murmurhash buffer.") + // console.log("Warning: exceeded maximum size of murmurhash buffer.") currentBuffer = new StaticArray(key.length) } diff --git a/lib/shared/bucketing-assembly-script/assembly/index.ts b/lib/shared/bucketing-assembly-script/assembly/index.ts index cd62f211b..73418949c 100644 --- a/lib/shared/bucketing-assembly-script/assembly/index.ts +++ b/lib/shared/bucketing-assembly-script/assembly/index.ts @@ -1,6 +1,6 @@ import { JSON } from '@devcycle/assemblyscript-json/assembly' import { - ConfigBody, + ConfigBodyV2 as ConfigBody, DVCPopulatedUser, FeatureVariation, PlatformData, diff --git a/lib/shared/bucketing-assembly-script/assembly/managers/configDataManager.ts b/lib/shared/bucketing-assembly-script/assembly/managers/configDataManager.ts index a90703226..9d2cab029 100644 --- a/lib/shared/bucketing-assembly-script/assembly/managers/configDataManager.ts +++ b/lib/shared/bucketing-assembly-script/assembly/managers/configDataManager.ts @@ -1,4 +1,4 @@ -import { ConfigBody } from '../types' +import { ConfigBodyV2 as ConfigBody } from '../types' const _configData: Map = new Map() diff --git a/lib/shared/bucketing-assembly-script/assembly/types/configBodyV2.ts b/lib/shared/bucketing-assembly-script/assembly/types/configBodyV2.ts index 3767c7c4f..83bdb07b6 100644 --- a/lib/shared/bucketing-assembly-script/assembly/types/configBodyV2.ts +++ b/lib/shared/bucketing-assembly-script/assembly/types/configBodyV2.ts @@ -8,8 +8,8 @@ import { getStringFromJSONOptional, } from '../helpers/jsonHelpers' import { FeatureV2 } from './featureV2' -import { Audience } from './targetV2' import { PublicEnvironment, PublicProject, Variable} from './configBody' +import { Audience } from './target' export class ConfigBodyV2 { readonly project: PublicProject diff --git a/lib/shared/bucketing-assembly-script/assembly/types/featureV2.ts b/lib/shared/bucketing-assembly-script/assembly/types/featureV2.ts index 212eb12e9..3e5ea3e49 100644 --- a/lib/shared/bucketing-assembly-script/assembly/types/featureV2.ts +++ b/lib/shared/bucketing-assembly-script/assembly/types/featureV2.ts @@ -1,12 +1,13 @@ import { JSON } from '@devcycle/assemblyscript-json/assembly' import { getJSONArrayFromJSON, - getJSONObjFromJSON, getJSONObjFromJSONOptional, getJSONValueFromJSON, + getJSONObjFromJSON, getJSONObjFromJSONOptional, getStringFromJSON, isValidString, jsonArrFromValueArray } from '../helpers/jsonHelpers' import { FeatureConfigurationV2 } from './featureConfigurationV2' +import { Variation } from './feature' const validTypes = ['release', 'experiment', 'permission', 'ops'] @@ -59,66 +60,3 @@ export class FeatureV2 extends JSON.Value { return json.stringify() } } - -export class Variation extends JSON.Value { - readonly _id: string - readonly name: string - readonly key: string - readonly variables: Array - - private readonly _variablesById: Map - - constructor(variation: JSON.Obj) { - super() - this._id = getStringFromJSON(variation, '_id') - - this.name = getStringFromJSON(variation, 'name') - this.key = getStringFromJSON(variation, 'key') - - const variablesJSON = getJSONArrayFromJSON(variation, 'variables').valueOf() - const variables = new Array() - const variablesById = new Map() - for (let i = 0; i < variablesJSON.length; i++) { - const variable = new VariationVariable(variablesJSON[i] as JSON.Obj) - variables.push(variable) - variablesById.set(variable._var, variable) - } - this.variables = variables - this._variablesById = variablesById - } - - getVariableById(variableId: string): VariationVariable | null { - return this._variablesById.has(variableId) - ? this._variablesById.get(variableId) - : null - } - - stringify(): string { - const json = new JSON.Obj() - json.set('_id', this._id) - json.set('name', this.name) - json.set('key', this.key) - json.set('variables', jsonArrFromValueArray(this.variables)) - - return json.stringify() - } -} - -export class VariationVariable extends JSON.Value { - readonly _var: string - readonly value: JSON.Value - - constructor(variable: JSON.Obj) { - super() - this._var = getStringFromJSON(variable, '_var') - this.value = getJSONValueFromJSON(variable, 'value') - } - - stringify(): string { - const json = new JSON.Obj() - json.set('_var', this._var) - json.set('value', this.value) - return json.stringify() - } -} - diff --git a/lib/shared/bucketing-assembly-script/assembly/types/target.ts b/lib/shared/bucketing-assembly-script/assembly/types/target.ts index 9ee3a626e..219c7fd10 100644 --- a/lib/shared/bucketing-assembly-script/assembly/types/target.ts +++ b/lib/shared/bucketing-assembly-script/assembly/types/target.ts @@ -43,6 +43,7 @@ export class Target extends JSON.Value { value: distribution[i]._variation }) } + this._sortedDistribution = sortObjectsByString(sortingArray, 'desc') } diff --git a/lib/shared/bucketing-assembly-script/assembly/types/targetV2.ts b/lib/shared/bucketing-assembly-script/assembly/types/targetV2.ts index 7ac2214d3..c2b26b782 100644 --- a/lib/shared/bucketing-assembly-script/assembly/types/targetV2.ts +++ b/lib/shared/bucketing-assembly-script/assembly/types/targetV2.ts @@ -1,24 +1,21 @@ import { JSON } from '@devcycle/assemblyscript-json/assembly' import { - getDateFromJSON, - getF64FromJSONObj, - getF64FromJSONOptional, getJSONArrayFromJSON, getJSONObjFromJSON, getStringFromJSON, getStringFromJSONOptional, isValidString, - isValidStringOptional, jsonArrFromValueArray } from '../helpers/jsonHelpers' import { SortingArray, sortObjectsByString } from '../helpers/arrayHelpers' +import { Audience, Rollout, TargetDistribution } from './target' export class TargetV2 extends JSON.Value { readonly _id: string readonly _audience: Audience readonly rollout: Rollout | null readonly distribution: TargetDistribution[] - readonly bucketingKey: string | null + readonly bucketingKey: string private readonly _sortedDistribution: TargetDistribution[] constructor(target: JSON.Obj) { @@ -43,7 +40,12 @@ export class TargetV2 extends JSON.Value { value: distribution[i]._variation }) } - this.bucketingKey = getStringFromJSONOptional(target, 'bucketingKey') + const bucketingKey = getStringFromJSONOptional(target, 'bucketingKey') + if (bucketingKey) { + this.bucketingKey = bucketingKey + } else { + this.bucketingKey = 'user_id' + } this._sortedDistribution = sortObjectsByString(sortingArray, 'desc') } @@ -94,78 +96,6 @@ export class AudienceFilter extends JSON.Value { return json.stringify() } } -// This is a virtual container class that's used by the code to determine whether a filter is a nested operator -// or a base level filter. This is used to support the recursive nature of nested filters. -// The config is returned to its original form when stringified. -export class AudienceFilterOrOperator extends JSON.Value { - readonly operatorClass: AudienceOperator | null - readonly filterClass: AudienceFilter | null - - constructor(filter: JSON.Obj) { - super() - const operator = isValidStringOptional(filter, 'operator', validAudienceOperators, false) - this.operatorClass = operator ? new AudienceOperator(filter) : null - this.filterClass = operator ? null : initializeFilterClass(filter) - } - - stringify(): string { - if (this.operatorClass !== null) { - return (this.operatorClass as AudienceOperator).stringify() - } - if (this.filterClass !== null) { - return (this.filterClass as AudienceFilter).stringify() - } - return '' - } -} - -export class AudienceOperator extends JSON.Value { - readonly operator: string - readonly filters: AudienceFilterOrOperator[] - - constructor(filter: JSON.Obj) { - super() - - this.operator = isValidString(filter, 'operator', validAudienceOperators, false) - - const filters = getJSONArrayFromJSON(filter, 'filters') - // Initialize AudienceFilterOrOperator - this.filters = [] - for (let i = 0; i < filters.valueOf().length; i ++) { - this.filters.push(new AudienceFilterOrOperator(filters.valueOf()[i] as JSON.Obj)) - } - } - - stringify(): string { - const json = new JSON.Obj() - if (this.operator) { - json.set('operator', this.operator) - } - if (this.filters) { - json.set('filters', jsonArrFromValueArray(this.filters as AudienceFilterOrOperator[])) - } - - return json.stringify() - } -} - -export class Audience extends JSON.Value { - readonly filters: AudienceOperator - - constructor(audience: JSON.Obj) { - super() - - this.filters = new AudienceOperator(getJSONObjFromJSON(audience, 'filters')) - } - - stringify(): string { - const json = new JSON.Obj() - json.set('filters', this.filters) - return json.stringify() - } -} - -const validAudienceOperators = ['and', 'or'] const validTypes = ['all', 'user', 'optIn', 'audienceMatch'] @@ -358,92 +288,3 @@ export class CustomDataFilter extends UserFilter { return json.stringify() } } - -function initializeFilterClass(filter: JSON.Obj): AudienceFilter { - if (getStringFromJSONOptional(filter, 'type') === 'user') { - if (getStringFromJSONOptional(filter, 'subType') === 'customData') { - return new CustomDataFilter(filter) - } - return new UserFilter(filter) - } else if (getStringFromJSONOptional(filter, 'type') === 'audienceMatch'){ - return new AudienceMatchFilter(filter) - } else { - return new AudienceFilter(filter) - } -} - -const validRolloutTypes = ['schedule', 'gradual', 'stepped'] - -export class Rollout extends JSON.Value { - readonly type: string - readonly startPercentage: f64 - readonly startDate: Date - readonly stages: RolloutStage[] | null - - constructor(rollout: JSON.Obj) { - super() - this.type = isValidString(rollout, 'type', validRolloutTypes) - - this.startPercentage = getF64FromJSONOptional(rollout, 'startPercentage', f64(1)) - - this.startDate = getDateFromJSON(rollout, 'startDate') - - const stages = rollout.getArr('stages') - this.stages = stages ? - stages.valueOf().map((stage) => { - return new RolloutStage(stage as JSON.Obj) - }) : null - } - - stringify(): string { - const json = new JSON.Obj() - json.set('type', this.type) - json.set('startPercentage', this.startPercentage) - json.set('startDate', this.startDate.toISOString()) - if (this.stages) { - json.set('stages', jsonArrFromValueArray(this.stages as RolloutStage[])) - } - return json.stringify() - } -} - -const validRolloutStages = ['linear', 'discrete'] - -export class RolloutStage extends JSON.Value { - readonly type: string - readonly date: Date - readonly percentage: f64 - - constructor(stage: JSON.Obj) { - super() - this.type = isValidString(stage, 'type', validRolloutStages) - this.date = getDateFromJSON(stage, 'date') - this.percentage = getF64FromJSONObj(stage, 'percentage') - } - - stringify(): string { - const json = new JSON.Obj() - json.set('type', this.type) - json.set('date', this.date.toISOString()) - json.set('percentage', this.percentage) - return json.stringify() - } -} - -export class TargetDistribution extends JSON.Value { - readonly _variation: string - readonly percentage: f64 - - constructor(distribution: JSON.Obj) { - super() - this._variation = getStringFromJSON(distribution, '_variation') - this.percentage = getF64FromJSONObj(distribution, 'percentage') - } - - stringify(): string { - const json = new JSON.Obj() - json.set('_variation', this._variation) - json.set('percentage', this.percentage) - return json.stringify() - } -}