diff --git a/src/asyncActions.ts b/src/asyncActions.ts index fa60d6c..94f9f7c 100644 --- a/src/asyncActions.ts +++ b/src/asyncActions.ts @@ -4,7 +4,7 @@ import { Dispatch, Action } from 'redux'; import { IInitSplitSdkParams, IGetTreatmentsParams, IDestroySplitSdkParams, ISplitFactoryBuilder } from './types'; import { splitReady, splitReadyWithEvaluations, splitReadyFromCache, splitReadyFromCacheWithEvaluations, splitTimedout, splitUpdate, splitUpdateWithEvaluations, splitDestroy, addTreatments } from './actions'; import { VERSION, ERROR_GETT_NO_INITSPLITSDK, ERROR_DESTROY_NO_INITSPLITSDK, getControlTreatmentsWithConfig } from './constants'; -import { matching, getStatus } from './utils'; +import { matching, getStatus, validateGetTreatmentsParams } from './utils'; /** * Internal object SplitSdk. This object should not be accessed or @@ -108,10 +108,8 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi return () => { }; } - // Convert string feature flag name to a one item array. - if (typeof params.splitNames === 'string') { - params.splitNames = [params.splitNames]; - } + params = validateGetTreatmentsParams(params); + const splitNames = params.splitNames as string[]; if (!splitSdk.isDetached) { // Split SDK running in Browser @@ -119,11 +117,11 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi // Register or unregister the current `getTreatments` action from being re-executed on SDK_UPDATE. if (params.evalOnUpdate) { - params.splitNames.forEach((featureFlagName) => { + splitNames.forEach((featureFlagName) => { client.evalOnUpdate[featureFlagName] = { ...params, splitNames: [featureFlagName] }; }); } else { - params.splitNames.forEach((featureFlagName) => { + splitNames.forEach((featureFlagName) => { delete client.evalOnUpdate[featureFlagName]; }); } @@ -147,14 +145,14 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi } else { // Otherwise, it adds control treatments to the store, without calling the SDK (no impressions sent) // @TODO remove eventually to minimize state changes - return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, getControlTreatmentsWithConfig(params.splitNames)); + return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, getControlTreatmentsWithConfig(splitNames)); } } else { // Split SDK running in Node // Evaluate Split and return redux action. const client = splitSdk.factory.client(); - const treatments = client.getTreatmentsWithConfig(params.key, params.splitNames, params.attributes); + const treatments = client.getTreatmentsWithConfig(params.key, splitNames, params.attributes); return addTreatments(params.key, treatments); } diff --git a/src/constants.ts b/src/constants.ts index f9ba036..c353fd1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -42,12 +42,12 @@ export const SPLIT_DESTROY = 'SPLIT_DESTROY'; export const ADD_TREATMENTS = 'ADD_TREATMENTS'; // Warning and error messages -export const ERROR_GETT_NO_INITSPLITSDK = '[Error] To use "getTreatments" the SDK must be first initialized with a "initSplitSdk" action'; +export const ERROR_GETT_NO_INITSPLITSDK = '[ERROR] To use "getTreatments" the SDK must be first initialized with a "initSplitSdk" action'; -export const ERROR_DESTROY_NO_INITSPLITSDK = '[Error] To use "destroySplitSdk" the SDK must be first initialized with a "initSplitSdk" action'; +export const ERROR_DESTROY_NO_INITSPLITSDK = '[ERROR] To use "destroySplitSdk" the SDK must be first initialized with a "initSplitSdk" action'; -export const ERROR_TRACK_NO_INITSPLITSDK = '[Error] To use "track" the SDK must be first initialized with an "initSplitSdk" action'; +export const ERROR_TRACK_NO_INITSPLITSDK = '[ERROR] To use "track" the SDK must be first initialized with an "initSplitSdk" action'; -export const ERROR_MANAGER_NO_INITSPLITSDK = '[Error] To use the manager, the SDK must be first initialized with an "initSplitSdk" action'; +export const ERROR_MANAGER_NO_INITSPLITSDK = '[ERROR] To use the manager, the SDK must be first initialized with an "initSplitSdk" action'; -export const ERROR_SELECTOR_NO_SPLITSTATE = '[Error] When using selectors, "splitState" value must be a proper splitio piece of state'; +export const ERROR_SELECTOR_NO_SPLITSTATE = '[ERROR] When using selectors, "splitState" value must be a proper splitio piece of state'; diff --git a/src/utils.ts b/src/utils.ts index dff2aab..1409026 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { IGetTreatmentsParams } from './types'; + /** * Validates if a value is an object. */ @@ -29,3 +31,78 @@ export function getStatus(client: SplitIO.IClient): IClientStatus { // @ts-expect-error, function exists but it is not part of JS SDK type definitions return client.__getStatus(); } + +/** + * Validates and sanitizes the parameters passed to the "getTreatments" action creator. + * + * @returns {IGetTreatmentsParams} The returned object is a copy of the passed one, with the "splitNames" property converted to an array of strings. + */ +export function validateGetTreatmentsParams(params: IGetTreatmentsParams) { + let { splitNames } = params; + if (typeof splitNames === 'string') splitNames = [splitNames]; + if (!validateFeatureFlags(splitNames)) splitNames = []; + + return { + ...params, + splitNames: splitNames, + }; +} + +// The following input validation utils are based on the ones in the React SDK. They might be replaced by utils from the JS SDK in the future. + +function validateFeatureFlags(maybeFeatureFlags: unknown, listName = 'feature flag names'): false | string[] { + if (Array.isArray(maybeFeatureFlags) && maybeFeatureFlags.length > 0) { + const validatedArray: string[] = []; + // Remove invalid values + maybeFeatureFlags.forEach((maybeFeatureFlag) => { + const featureFlagName = validateFeatureFlag(maybeFeatureFlag); + if (featureFlagName) validatedArray.push(featureFlagName); + }); + + // Strip off duplicated values if we have valid feature flag names then return + if (validatedArray.length) return uniq(validatedArray); + } + + console.log(`[ERROR] ${listName} must be a non-empty array.`); + return false; +} + +const TRIMMABLE_SPACES_REGEX = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/; + +function validateFeatureFlag(maybeFeatureFlag: unknown, item = 'feature flag name'): false | string { + if (maybeFeatureFlag == undefined) { + console.log(`[ERROR] you passed a null or undefined ${item}, ${item} must be a non-empty string.`); + } else if (!isString(maybeFeatureFlag)) { + console.log(`[ERROR] you passed an invalid ${item}, ${item} must be a non-empty string.`); + } else { + if (TRIMMABLE_SPACES_REGEX.test(maybeFeatureFlag)) { + console.log(`[WARN] ${item} "${maybeFeatureFlag}" has extra whitespace, trimming.`); + maybeFeatureFlag = maybeFeatureFlag.trim(); + } + + if ((maybeFeatureFlag as string).length > 0) { + return maybeFeatureFlag as string; + } else { + console.log(`[ERROR] you passed an empty ${item}, ${item} must be a non-empty string.`); + } + } + + return false; +} + +/** + * Removes duplicate items on an array of strings. + */ +function uniq(arr: string[]): string[] { + const seen: Record = {}; + return arr.filter((item) => { + return Object.prototype.hasOwnProperty.call(seen, item) ? false : seen[item] = true; + }); +} + +/** + * Checks if a given value is a string. + */ +function isString(val: unknown): val is string { + return typeof val === 'string' || val instanceof String; +} diff --git a/types/utils.d.ts b/types/utils.d.ts index 8608b7b..241e116 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -1,3 +1,4 @@ +import { IGetTreatmentsParams } from './types'; /** * Validates if a value is an object. */ @@ -17,3 +18,15 @@ export interface IClientStatus { isDestroyed: boolean; } export declare function getStatus(client: SplitIO.IClient): IClientStatus; +/** + * Validates and sanitizes the parameters passed to the "getTreatments" action creator. + * + * @returns {IGetTreatmentsParams} The returned object is a copy of the passed one, with the "splitNames" property converted to an array of strings. + */ +export declare function validateGetTreatmentsParams(params: IGetTreatmentsParams): { + splitNames: string[]; + key?: import("@splitsoftware/splitio/types/splitio").SplitKey; + attributes?: import("@splitsoftware/splitio/types/splitio").Attributes; + evalOnUpdate?: boolean; + evalOnReadyFromCache?: boolean; +};