diff --git a/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts new file mode 100644 index 00000000..bb6c0df7 --- /dev/null +++ b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts @@ -0,0 +1,211 @@ +import { clientAttributesDecoration } from '../clientAttributesDecoration'; +import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; + +// mocked methods return the provided attributes object (2nd argument), to assert that it was properly passed +const clientMock = { + getTreatment(maybeKey: any, maybeSplit: string, maybeAttributes?: any) { + return maybeAttributes; + }, + getTreatmentWithConfig(maybeKey: any, maybeSplit: string, maybeAttributes?: any) { + return maybeAttributes; + }, + getTreatments(maybeKey: any, maybeSplits: string[], maybeAttributes?: any) { + return maybeAttributes; + }, + getTreatmentsWithConfig(maybeKey: any, maybeSplits: string[], maybeAttributes?: any) { + return maybeAttributes; + } +}; +// @ts-expect-error +const client = clientAttributesDecoration(loggerMock, clientMock); + +test('ATTRIBUTES DECORATION / storage', () => { + + client.setAttribute('attributeName1', 'attributeValue1'); + client.setAttribute('attributeName2', 'attributeValue2'); + + expect(client.getAttributes()).toEqual({ attributeName1: 'attributeValue1', attributeName2: 'attributeValue2' }); // It should be equal + + client.removeAttribute('attributeName1'); + client.setAttribute('attributeName2', 'newAttributeValue2'); + + expect(client.getAttribute('attributeName1')).toEqual(undefined); // It should throw undefined + expect(client.getAttribute('attributeName2')).toEqual('newAttributeValue2'); // It should be equal + + client.setAttributes({ + 'attributeName3': 'attributeValue3', + 'attributeName4': 'attributeValue4' + }); + + expect(client.getAttributes()).toEqual({ attributeName2: 'newAttributeValue2', attributeName3: 'attributeValue3', attributeName4: 'attributeValue4' }); // It should be equal + + expect(client.clearAttributes()).toEqual(true); + + expect(Object.keys(client.getAttributes()).length).toEqual(0); // It should be zero after clearing attributes + +}); + + +describe('ATTRIBUTES DECORATION / validation', () => { + + test('Should return true if it is a valid attributes map without logging any errors', () => { + const validAttributes = { amIvalid: 'yes', 'are_you_sure': true, howMuch: 10, 'spell': ['1', '0'] }; + + expect(client.setAttributes(validAttributes)).toEqual(true); // It should return true if it is valid. + expect(client.getAttributes()).toEqual(validAttributes); // It should be the same. + expect(client.setAttribute('attrKey', 'attrValue')).toEqual(true); // It should return true. + expect(client.getAttribute('attrKey')).toEqual('attrValue'); // It should return true. + + expect(client.removeAttribute('attrKey')).toEqual(true); // It should return true. + expect(client.getAttributes()).toEqual(validAttributes); // It should be equal to the first set. + + expect(client.clearAttributes()).toEqual(true); + + expect(Object.keys(client.getAttributes()).length).toEqual(0); // It should be zero after clearing attributes + + }); + + test('Should return false if it is an invalid attributes map', () => { + expect(client.setAttribute('', 'attributeValue')).toEqual(false); // It should be invalid if the attribute key is not a string + expect(client.setAttribute('attributeKey1', new Date())).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey2', { 'some': 'object' })).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey3', Infinity)).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + + expect(client.clearAttributes()).toEqual(true); + + let values = client.getAttributes(); + + expect(Object.keys(values).length).toEqual(0); // It should be zero after clearing attributes + + let attributes = { + 'attributeKey': 'attributeValue', + '': 'attributeValue' + }; + + expect(client.setAttributes(attributes)).toEqual(false); // It should be invalid if the attribute key is not a string + + expect(Object.keys(client.getAttributes()).length).toEqual(0); // It should be zero after trying to add an invalid attribute + + expect(client.clearAttributes()).toEqual(true); + + }); + + test('Should return true if attributes map is valid', () => { + const validAttributes = { + 'attributeKey1': 'attributeValue', + 'attributeKey2': ['attribute', 'value'], + 'attributeKey3': 25, + 'attributeKey4': false + }; + + expect(client.setAttribute('attributeKey1', 'attributeValue')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey2', ['attribute', 'value'])).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey3', 25)).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey4', false)).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(client.setAttribute('attributeKey5', Date.now())).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + + expect(client.removeAttribute('attributeKey5')).toEqual(true); // It should be capable of remove the attribute with that name + expect(client.getAttributes()).toEqual(validAttributes); // It should had stored every valid attributes. + + expect(client.clearAttributes()).toEqual(true); + + expect(client.setAttributes(validAttributes)).toEqual(true); // It should add them all because they are valid attributes. + expect(client.getAttributes()).toEqual(validAttributes); // It should had stored every valid attributes. + + expect(client.clearAttributes()).toEqual(true); + + }); + +}); + +describe('ATTRIBUTES DECORATION / evaluation', () => { + + test('Evaluation attributes logic and precedence / getTreatment', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatment('key', 'split')).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatment('key', 'split', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatment('key', 'split', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatment('key', 'split', null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatment('key', 'split', { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatment('key', 'split', { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatment('key', 'split', null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatment('key', 'split')).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.clearAttributes()).toEqual(true); + + }); + + test('Evaluation attributes logic and precedence / getTreatments', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatments('key', ['split'])).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatments('key', ['split'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatments('key', ['split'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatments('key', ['split'], null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatments('key', ['split'], { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatments('key', ['split'], { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatments('key', ['split'], null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatments('key', ['split'])).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.clearAttributes()).toEqual(true); + + }); + + test('Evaluation attributes logic and precedence / getTreatmentWithConfig', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentWithConfig('key', 'split')).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentWithConfig('key', 'split', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentWithConfig('key', 'split', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentWithConfig('key', 'split', null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentWithConfig('key', 'split', { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentWithConfig('key', 'split', { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentWithConfig('key', 'split', null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentWithConfig('key', 'split')).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.clearAttributes()).toEqual(true); + + }); + + test('Evaluation attributes logic and precedence / getTreatmentsWithConfig', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentsWithConfig('key', ['split'])).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentsWithConfig('key', ['split'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentsWithConfig('key', ['split'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfig('key', ['split'], null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentsWithConfig('key', ['split'], { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentsWithConfig('key', ['split'], { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfig('key', ['split'], null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentsWithConfig('key', ['split'])).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + client.clearAttributes(); + + }); + +}); diff --git a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts index 0a5c8e59..b5b6b21e 100644 --- a/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethodCS.spec.ts @@ -51,6 +51,20 @@ const params = { settings: settingsWithKey }; +const invalidAttributes = [ + new Date(), + { some: 'object' }, + Infinity +]; + +const validAttributes = [ + 25, // number + Date.now(), // number + 'string', // string + ['string', 'list'], // list + false // boolean +]; + /** End mocks */ describe('sdkClientMethodCSFactory', () => { @@ -227,8 +241,94 @@ describe('sdkClientMethodCSFactory', () => { assertClientApi(client, params.sdkReadinessManager.sdkStatus); // but with false as binded key and TT - if (ignoresTT) expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), false); - else expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), false, false); + if (ignoresTT) expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), false); + else expect(clientCSDecoratorSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), false, false); + }); + + test.each(testTargets)('attributes binding - main client', (sdkClientMethodCSFactory) => { + // @ts-expect-error + const sdkClientMethod = sdkClientMethodCSFactory(params); + + // should return a function + expect(typeof sdkClientMethod).toBe('function'); + + // calling the function should return a client instance + const client = sdkClientMethod(); + assertClientApi(client, params.sdkReadinessManager.sdkStatus); + + expect(client.setAttribute('attributeName1', 'attributeValue1')).toEqual(true); + expect(client.setAttribute('attributeName2', 'attributeValue2')).toEqual(true); + + expect(client.setAttribute('', 'empty')).toEqual(false); // Attribute name should not be an empty string + expect(client.setAttribute({ '': 'empty' })).toEqual(false); // Attribute name should not be an empty string + + invalidAttributes.forEach(invalidValue => { + expect(client.setAttribute('attributeName', invalidValue)).toEqual(false); + expect(client.setAttributes({ attributeName: invalidValue })).toEqual(false); + }); + + expect(client.getAttributes()).toEqual({ attributeName1: 'attributeValue1', attributeName2: 'attributeValue2' }); + + validAttributes.forEach(validValue => { + expect(client.setAttribute('attributeName', validValue)).toEqual(true); + }); + + validAttributes.forEach(validValue => { + expect(client.setAttributes({ attributeName: validValue })).toEqual(true); + }); + + expect(client.getAttributes()).toEqual({ attributeName: false, attributeName1: 'attributeValue1', attributeName2: 'attributeValue2' }); + + expect(client.removeAttribute('attributeName1')).toEqual(true); + + expect(client.getAttribute('attributeName1')).toEqual(undefined); + expect(client.getAttribute('attributeName2')).toEqual('attributeValue2'); + + expect(client.setAttributes({ + 'attributeName3': 'attributeValue3', + 'attributeName4': 'attributeValue4' + })).toEqual(true); + + expect(client.getAttribute('attributeName2')).toEqual('attributeValue2'); + expect(client.getAttribute('attributeName3')).toEqual('attributeValue3'); + expect(client.getAttribute('attributeName4')).toEqual('attributeValue4'); + + expect(client.clearAttributes()).toEqual(true); + + expect(client.getAttributes()).toEqual({}); + }); + + + test.each(testTargets)('attributes binding - shared clients', (sdkClientMethodCSFactory) => { + // @ts-expect-error + const sdkClientMethod = sdkClientMethodCSFactory(params); + + // should return a function + expect(typeof sdkClientMethod).toBe('function'); + + // calling the function should return a client instance + const emmanuelClient = sdkClientMethod('emmanuel@split.io'); + const emilianoClient = sdkClientMethod('emiliano@split.io'); + assertClientApi(emmanuelClient); + assertClientApi(emilianoClient); + + expect(emmanuelClient.setAttribute('name', 'Emmanuel')).toEqual(true); + expect(emilianoClient.setAttribute('name', 'Emiliano')).toEqual(true); + + expect(emmanuelClient.getAttribute('name')).toEqual('Emmanuel'); + expect(emilianoClient.getAttribute('name')).toEqual('Emiliano'); + + expect(emmanuelClient.setAttributes({ email: 'emmanuel@split.io' })).toEqual(true); + expect(emilianoClient.setAttributes({ email: 'emiliano@split.io' })).toEqual(true); + + expect(emmanuelClient.getAttributes()).toEqual({ name: 'Emmanuel', email: 'emmanuel@split.io' }); + expect(emilianoClient.getAttributes()).toEqual({ name: 'Emiliano', email: 'emiliano@split.io' }); + + expect(emmanuelClient.clearAttributes()).toEqual(true); + + expect(emmanuelClient.getAttributes()).toEqual({}); + expect(emilianoClient.getAttributes()).toEqual({ name: 'Emiliano', email: 'emiliano@split.io' }); + }); }); diff --git a/src/sdkClient/clientAttributesDecoration.ts b/src/sdkClient/clientAttributesDecoration.ts new file mode 100644 index 00000000..5d55c7df --- /dev/null +++ b/src/sdkClient/clientAttributesDecoration.ts @@ -0,0 +1,122 @@ +import { AttributesCacheInMemory } from '../storages/inMemory/AttributesCacheInMemory'; +import { validateAttributesDeep } from '../utils/inputValidation/attributes'; +import { SplitIO } from '../types'; +import { ILogger } from '../logger/types'; +import { objectAssign } from '../utils/lang/objectAssign'; + +/** + * Add in memory attributes storage methods and combine them with any attribute received from the getTreatment/s call + */ +export function clientAttributesDecoration(log: ILogger, client: TClient) { + + const attributeStorage = new AttributesCacheInMemory(); + + // Keep a reference to the original methods + const clientGetTreatment = client.getTreatment; + const clientGetTreatmentWithConfig = client.getTreatmentWithConfig; + const clientGetTreatments = client.getTreatments; + const clientGetTreatmentsWithConfig = client.getTreatmentsWithConfig; + const clientTrack = client.track; + + function getTreatment(maybeKey: SplitIO.SplitKey, maybeSplit: string, maybeAttributes?: SplitIO.Attributes) { + return clientGetTreatment(maybeKey, maybeSplit, combineAttributes(maybeAttributes)); + } + + function getTreatmentWithConfig(maybeKey: SplitIO.SplitKey, maybeSplit: string, maybeAttributes?: SplitIO.Attributes) { + return clientGetTreatmentWithConfig(maybeKey, maybeSplit, combineAttributes(maybeAttributes)); + } + + function getTreatments(maybeKey: SplitIO.SplitKey, maybeSplits: string[], maybeAttributes?: SplitIO.Attributes) { + return clientGetTreatments(maybeKey, maybeSplits, combineAttributes(maybeAttributes)); + } + + function getTreatmentsWithConfig(maybeKey: SplitIO.SplitKey, maybeSplits: string[], maybeAttributes?: SplitIO.Attributes) { + return clientGetTreatmentsWithConfig(maybeKey, maybeSplits, combineAttributes(maybeAttributes)); + } + + function track(maybeKey: SplitIO.SplitKey, maybeTT: string, maybeEvent: string, maybeEventValue?: number, maybeProperties?: SplitIO.Properties) { + return clientTrack(maybeKey, maybeTT, maybeEvent, maybeEventValue, maybeProperties); + } + + function combineAttributes(maybeAttributes: SplitIO.Attributes | undefined): SplitIO.Attributes | undefined{ + const storedAttributes = attributeStorage.getAll(); + if (Object.keys(storedAttributes).length > 0) { + return objectAssign({}, storedAttributes, maybeAttributes); + } + return maybeAttributes; + } + + return objectAssign(client, { + getTreatment: getTreatment, + getTreatmentWithConfig: getTreatmentWithConfig, + getTreatments: getTreatments, + getTreatmentsWithConfig: getTreatmentsWithConfig, + track: track, + + /** + * Add an attribute to client's in memory attributes storage + * + * @param {string} attributeName Attrinute name + * @param {string, number, boolean, list} attributeValue Attribute value + * @returns {boolean} true if the attribute was stored and false otherways + */ + setAttribute(attributeName: string, attributeValue: Object) { + const attribute: Record = {}; + attribute[attributeName] = attributeValue; + if (!validateAttributesDeep(log, attribute, 'setAttribute')) return false; + log.debug(`stored ${attributeValue} for attribute ${attributeName}`); + return attributeStorage.setAttribute(attributeName, attributeValue); + }, + + /** + * Returns the attribute with the given key + * + * @param {string} attributeName Attribute name + * @returns {Object} Attribute with the given key + */ + getAttribute(attributeName: string) { + log.debug(`retrieved attribute ${attributeName}`); + return attributeStorage.getAttribute(attributeName + ''); + }, + + /** + * Add to client's in memory attributes storage the attributes in 'attributes' + * + * @param {Object} attributes Object with attributes to store + * @returns true if attributes were stored an false otherways + */ + setAttributes(attributes: Record) { + if (!validateAttributesDeep(log, attributes, 'setAttributes')) return false; + return attributeStorage.setAttributes(attributes); + }, + + /** + * Return all the attributes stored in client's in memory attributes storage + * + * @returns {Object} returns all the stored attributes + */ + getAttributes(): Record { + return attributeStorage.getAll(); + }, + + /** + * Removes from client's in memory attributes storage the attribute with the given key + * + * @param {string} attributeName + * @returns {boolean} true if attribute was removed and false otherways + */ + removeAttribute(attributeName: string) { + log.debug(`removed attribute ${attributeName}`); + return attributeStorage.removeAttribute(attributeName + ''); + }, + + /** + * Remove all the stored attributes in the client's in memory attribute storage + */ + clearAttributes() { + return attributeStorage.clear(); + } + + }); + +} diff --git a/src/sdkClient/clientCS.ts b/src/sdkClient/clientCS.ts index dee6a6af..8cf0d6a2 100644 --- a/src/sdkClient/clientCS.ts +++ b/src/sdkClient/clientCS.ts @@ -1,5 +1,7 @@ import { objectAssign } from '../utils/lang/objectAssign'; +import { ILogger } from '../logger/types'; import { SplitIO } from '../types'; +import { clientAttributesDecoration } from './clientAttributesDecoration'; /** @@ -9,15 +11,18 @@ import { SplitIO } from '../types'; * @param key validated split key * @param trafficType validated traffic type */ -export function clientCSDecorator(client: SplitIO.IClient, key: SplitIO.SplitKey, trafficType?: string): SplitIO.ICsClient { - return objectAssign(client, { +export function clientCSDecorator(log: ILogger, client: SplitIO.IClient, key: SplitIO.SplitKey, trafficType?: string): SplitIO.ICsClient { + + let clientCS = clientAttributesDecoration(log, client); + + return objectAssign(clientCS, { // In the client-side API, we bind a key to the client `getTreatment*` methods - getTreatment: client.getTreatment.bind(client, key), - getTreatmentWithConfig: client.getTreatmentWithConfig.bind(client, key), - getTreatments: client.getTreatments.bind(client, key), - getTreatmentsWithConfig: client.getTreatmentsWithConfig.bind(client, key), + getTreatment: clientCS.getTreatment.bind(clientCS, key), + getTreatmentWithConfig: clientCS.getTreatmentWithConfig.bind(clientCS, key), + getTreatments: clientCS.getTreatments.bind(clientCS, key), + getTreatmentsWithConfig: clientCS.getTreatmentsWithConfig.bind(clientCS, key), // Key is bound to the `track` method. Same thing happens with trafficType but only if provided - track: trafficType ? client.track.bind(client, key, trafficType) : client.track.bind(client, key) + track: trafficType ? clientCS.track.bind(clientCS, key, trafficType) : clientCS.track.bind(clientCS, key) }) as SplitIO.ICsClient; } diff --git a/src/sdkClient/sdkClientMethodCS.ts b/src/sdkClient/sdkClientMethodCS.ts index c0d28f60..2abd7a20 100644 --- a/src/sdkClient/sdkClientMethodCS.ts +++ b/src/sdkClient/sdkClientMethodCS.ts @@ -29,6 +29,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? const validKey = validateKey(log, key, method); const mainClientInstance = clientCSDecorator( + log, sdkClientFactory(params) as SplitIO.IClient, // @ts-ignore validKey ); @@ -74,6 +75,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? // As shared clients reuse all the storage information, we don't need to check here if we // will use offline or online mode. We should stick with the original decision. clientInstances[instanceId] = clientCSDecorator( + log, sdkClientFactory(objectAssign({}, params, { sdkReadinessManager: sharedSdkReadiness, storage: sharedStorage || storage, diff --git a/src/sdkClient/sdkClientMethodCSWithTT.ts b/src/sdkClient/sdkClientMethodCSWithTT.ts index 1c844296..7c5427e2 100644 --- a/src/sdkClient/sdkClientMethodCSWithTT.ts +++ b/src/sdkClient/sdkClientMethodCSWithTT.ts @@ -35,6 +35,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? } const mainClientInstance = clientCSDecorator( + log, sdkClientFactory(params) as SplitIO.IClient, // @ts-ignore validKey, validTrafficType @@ -88,6 +89,7 @@ export function sdkClientMethodCSFactory(params: ISdkClientFactoryParams): (key? // As shared clients reuse all the storage information, we don't need to check here if we // will use offline or online mode. We should stick with the original decision. clientInstances[instanceId] = clientCSDecorator( + log, sdkClientFactory(objectAssign({}, params, { sdkReadinessManager: sharedSdkReadiness, storage: sharedStorage || storage, diff --git a/src/storages/inMemory/AttributesCacheInMemory.ts b/src/storages/inMemory/AttributesCacheInMemory.ts new file mode 100644 index 00000000..455d35e1 --- /dev/null +++ b/src/storages/inMemory/AttributesCacheInMemory.ts @@ -0,0 +1,73 @@ +import { objectAssign } from '../../utils/lang/objectAssign'; + +export class AttributesCacheInMemory { + + private attributesCache: Record = {}; + + + /** + * Create or update the value for the given attribute + * + * @param {string} attributeName attribute name + * @param {Object} attributeValue attribute value + * @returns {boolean} the attribute was stored + */ + setAttribute(attributeName: string, attributeValue: Object): boolean { + this.attributesCache[attributeName] = attributeValue; + return true; + } + + /** + * Retrieves the value of a given attribute + * + * @param {string} attributeName attribute name + * @returns {Object?} stored attribute value + */ + getAttribute(attributeName: string): Object { + return this.attributesCache[attributeName]; + } + + /** + * Create or update all the given attributes + * + * @param {[string, Object]} attributes attributes to create or update + * @returns {boolean} attributes were stored + */ + setAttributes(attributes: Record): boolean { + this.attributesCache = objectAssign(this.attributesCache, attributes); + return true; + } + + /** + * Retrieve the full attributes map + * + * @returns {Map} stored attributes + */ + getAll(): Record { + return this.attributesCache; + } + + /** + * Removes a given attribute from the map + * + * @param {string} attributeName attribute to remove + * @returns {boolean} attribute removed + */ + removeAttribute(attributeName: string): boolean { + if (Object.keys(this.attributesCache).indexOf(attributeName) >= 0) { + delete this.attributesCache[attributeName]; + return true; + } + return false; + } + + /** + * Clears all attributes stored in the SDK + * + */ + clear() { + this.attributesCache = {}; + return true; + } + +} diff --git a/src/storages/inMemory/__tests__/AttributesCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/AttributesCacheInMemory.spec.ts new file mode 100644 index 00000000..12863369 --- /dev/null +++ b/src/storages/inMemory/__tests__/AttributesCacheInMemory.spec.ts @@ -0,0 +1,30 @@ +import { AttributesCacheInMemory } from '../AttributesCacheInMemory'; + +test('ATTRIBUTES CACHE / Attributes in memory storage test basic usage ', () => { + + const cache = new AttributesCacheInMemory(); + + expect(cache.setAttribute('attributeName1', 'attributeValue1')).toEqual(true); + expect(cache.setAttribute('attributeName2', 'attributeValue2')).toEqual(true); + + expect(cache.getAll()).toEqual({ attributeName1: 'attributeValue1', attributeName2: 'attributeValue2' }); + + expect(cache.removeAttribute('attributeName1')).toEqual(true); + + expect(cache.getAttribute('attributeName1')).toEqual(undefined); + expect(cache.getAttribute('attributeName2')).toEqual('attributeValue2'); + + expect(cache.setAttributes({ + 'attributeName3': 'attributeValue3', + 'attributeName4': 'attributeValue4' + })).toEqual(true); + + expect(cache.getAttribute('attributeName2')).toEqual('attributeValue2'); + expect(cache.getAttribute('attributeName3')).toEqual('attributeValue3'); + expect(cache.getAttribute('attributeName4')).toEqual('attributeValue4'); + + expect(cache.clear()).toEqual(true); + + expect(cache.getAll()).toEqual({}); + +}); diff --git a/src/types.ts b/src/types.ts index d4351696..9a94dd45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1108,6 +1108,45 @@ export namespace SplitIO { * @returns {boolean} Whether the event was added to the queue succesfully or not. */ track(...args: [trafficType: string, eventType: string, value?: number, properties?: Properties] | [eventType: string, value?: number, properties?: Properties]): boolean, + /** + * Add an attribute to client's in memory attributes storage + * @function setAttribute + * @param {string} attributeName Attrinute name + * @param {string, number, boolean, list} attributeValue Attribute value + * @returns {boolean} true if the attribute was stored and false otherways + */ + setAttribute(attributeName: string, attributeValue: Object): boolean, + /** + * Returns the attribute with the given key + * @function getAttribute + * @param {string} attributeName Attribute name + * @returns {Object} Attribute with the given key + */ + getAttribute(attributeName: string): Object, + /** + * Add to client's in memory attributes storage the attributes in 'attributes' + * @function setAttributes + * @param {Object} attributes Object with attributes to store + * @returns true if attributes were stored an false otherways + */ + setAttributes(attributes: Record): boolean, + /** + * Return all the attributes stored in client's in memory attributes storage + * @function getAttributes + * @returns {Object} returns all the stored attributes + */ + getAttributes(): Record, + /** + * Removes from client's in memory attributes storage the attribute with the given key + * @function removeAttribute + * @param {string} attributeName + * @returns {boolean} true if attribute was removed and false otherways + */ + removeAttribute(attributeName: string): boolean, + /** + * Remove all the stored attributes in the client's in memory attribute storage + */ + clearAttributes(): boolean } /** * Representation of a manager instance with synchronous storage of the SDK. diff --git a/src/utils/inputValidation/__tests__/attribute.spec.ts b/src/utils/inputValidation/__tests__/attribute.spec.ts new file mode 100644 index 00000000..a9d7f6e4 --- /dev/null +++ b/src/utils/inputValidation/__tests__/attribute.spec.ts @@ -0,0 +1,27 @@ +import { validateAttribute } from '../attribute'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + + +test('INPUT VALIDATION for Attribute', () => { + + // @ts-ignore + expect(validateAttribute(loggerMock, 2, 'dos', 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + expect(validateAttribute(loggerMock, '', 'empty', 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + // @ts-ignore + expect(validateAttribute(loggerMock, null, 'null', 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + // @ts-ignore + expect(validateAttribute(loggerMock, true, 'boolean', 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + // @ts-ignore + expect(validateAttribute(loggerMock, {'some':'object'}, 'object', 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + + expect(validateAttribute(loggerMock, 'attributeKey', new Date(), 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', { 'some': 'object' }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', Infinity, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + + expect(validateAttribute(loggerMock, 'attributeKey', 'attributeValue', 'some_method_attrs')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', ['attribute', 'value'], 'some_method_attrs')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', 25, 'some_method_attrs')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', false, 'some_method_attrs')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttribute(loggerMock, 'attributeKey', Date.now(), 'some_method_attrs')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + +}); diff --git a/src/utils/inputValidation/__tests__/attributes.spec.ts b/src/utils/inputValidation/__tests__/attributes.spec.ts index e414b4ea..cd6e7052 100644 --- a/src/utils/inputValidation/__tests__/attributes.spec.ts +++ b/src/utils/inputValidation/__tests__/attributes.spec.ts @@ -1,7 +1,7 @@ import { ERROR_NOT_PLAIN_OBJECT } from '../../../logger/constants'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -import { validateAttributes } from '../attributes'; +import { validateAttributes, validateAttributesDeep } from '../attributes'; const invalidAttributes = [ [], @@ -49,3 +49,42 @@ describe('INPUT VALIDATION for Attributes', () => { expect(loggerMock.warn).not.toBeCalled(); // It should have not logged any warnings. }); }); + +describe('DEEP INPUT VALIDATION for Attributes', () => { + + afterEach(() => { loggerMock.mockClear(); }); + + test('Should return true if it is a valid attributes map without logging any errors', () => { + const validAttributes = { amIvalid: 'yes', 'are_you_sure': true, howMuch: 10, 'spell': ['1', '0'] }; + + expect(validateAttributesDeep(loggerMock, validAttributes, 'some_method_attrs')).toEqual(true); // It should return true if it is valid. + expect(loggerMock.error).not.toBeCalled(); // Should not log any errors. + expect(loggerMock.warn).not.toBeCalled(); // It should have not logged any warnings. + + }); + + test('Should return false and log error if attributes map is invalid', () => { + + expect(validateAttributesDeep(loggerMock, { '': 'empty' }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute key is not a string + // @ts-ignore + expect(validateAttributesDeep(loggerMock, { 'attrKey': null }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': new Date() }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': { 'some': 'object' } }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': Infinity }, 'some_method_attrs')).toEqual(false); // It should be invalid if the attribute value is not a String, Number, Boolean or Lists. + + expect(loggerMock.error).not.toBeCalled(); // Should not log any errors. + + }); + + test('Should return true if attributes map is valid', () => { + expect(validateAttributesDeep(loggerMock, { 'attributeKey': 'attributeValue' }, 'some_method_args')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': ['attribute', 'value'] }, 'some_method_args')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': 25 }, 'some_method_args')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': false }, 'some_method_args')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + expect(validateAttributesDeep(loggerMock, { 'attributeKey': Date.now() }, 'some_method_args')).toEqual(true); // It should be valid if the attribute value is a String, Number, Boolean or Lists. + + expect(loggerMock.error).not.toBeCalled(); // Should not log any errors. + expect(loggerMock.warn).not.toBeCalled(); // It should have not logged any warnings. + + }); +}); diff --git a/src/utils/inputValidation/attribute.ts b/src/utils/inputValidation/attribute.ts new file mode 100644 index 00000000..e30d9473 --- /dev/null +++ b/src/utils/inputValidation/attribute.ts @@ -0,0 +1,21 @@ +import { isString, isFiniteNumber, isBoolean } from '../../utils/lang'; +import { ILogger } from '../../logger/types'; + +export function validateAttribute(log: ILogger, attributeKey: string, attributeValue: Object, method: string): boolean { + if (!isString(attributeKey) || attributeKey.length === 0) { + log.warn(`${method}: you passed an invalid attribute name, attribute name must be a non-empty string.`); + return false; + } + + const isStringVal = isString(attributeValue); + const isFiniteVal = isFiniteNumber(attributeValue); + const isBoolVal = isBoolean(attributeValue); + const isArrayVal = Array.isArray(attributeValue); + + if (!(isStringVal || isFiniteVal || isBoolVal || isArrayVal)) { // If it's not of valid type. + log.warn(`${method}: you passed an invalid attribute value for ${attributeKey}. Acceptable types are: string, number, boolean and array of strings.`); + return false; + } + + return true; +} diff --git a/src/utils/inputValidation/attributes.ts b/src/utils/inputValidation/attributes.ts index e45f67c0..7a06ee90 100644 --- a/src/utils/inputValidation/attributes.ts +++ b/src/utils/inputValidation/attributes.ts @@ -1,6 +1,7 @@ import { isObject } from '../lang'; import { SplitIO } from '../../types'; import { ILogger } from '../../logger/types'; +import { validateAttribute } from './attribute'; import { ERROR_NOT_PLAIN_OBJECT } from '../../logger/constants'; export function validateAttributes(log: ILogger, maybeAttrs: any, method: string): SplitIO.Attributes | undefined | false { @@ -11,3 +12,16 @@ export function validateAttributes(log: ILogger, maybeAttrs: any, method: string log.error(ERROR_NOT_PLAIN_OBJECT, [method, 'attributes']); return false; } + +export function validateAttributesDeep(log: ILogger, maybeAttributes: Record, method: string): boolean { + if (!validateAttributes(log, maybeAttributes, method)) return false; + + let result = true; + Object.keys(maybeAttributes).forEach(attributeKey => { + if (!validateAttribute(log, attributeKey, maybeAttributes[attributeKey], method)) + result = false; + }); + + return result; + +}