diff --git a/CHANGES.txt b/CHANGES.txt index b6952027..892a6b9d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +1.2.0 (January 19, 2022) + - Added support to SDK clients on browser to optionally bind attributes to the client, keeping these loaded within the SDK along with the user ID, for easier usage when requesting flag. + 1.1.0 (January 11, 2022) - Added support for the SDK to run in "consumer" and "partial consumer" modes, with a pluggable implementation of it's internal storage, enabling customers to implement this caching with any storage technology of choice and connect it to the SDK instance to be used instead of its default in-memory storage. diff --git a/package-lock.json b/package-lock.json index 0b72381e..7be002a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index dc09153e..9310f51f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.1.0", + "version": "1.2.0", "description": "Split Javascript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", 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; + +}