import { validateSplits } from '../inputValidation/splits'; import { ISplitFiltersValidation } from '../../dtos/types'; import SplitIO from '../../../types/splitio'; import { ILogger } from '../../logger/types'; import { WARN_SPLITS_FILTER_IGNORED, WARN_SPLITS_FILTER_EMPTY, WARN_SPLITS_FILTER_INVALID, SETTINGS_SPLITS_FILTER, LOG_PREFIX_SETTINGS, ERROR_SETS_FILTER_EXCLUSIVE, WARN_LOWERCASE_FLAGSET, WARN_INVALID_FLAGSET, WARN_FLAGSET_NOT_CONFIGURED } from '../../logger/constants'; import { objectAssign } from '../lang/objectAssign'; import { find, uniq } from '../lang'; import { isConsumerMode } from './mode'; // Split filters metadata. // Ordered according to their precedency when forming the filter query string: `&names=&prefixes=` const FILTERS_METADATA = [ { type: 'bySet' as SplitIO.SplitFilterType, maxLength: 50, queryParam: 'sets=' }, { type: 'byName' as SplitIO.SplitFilterType, maxLength: 400, queryParam: 'names=' }, { type: 'byPrefix' as SplitIO.SplitFilterType, maxLength: 50, queryParam: 'prefixes=' } ]; const VALID_FLAGSET_REGEX = /^[a-z0-9][_a-z0-9]{0,49}$/; const CAPITAL_LETTERS_REGEX = /[A-Z]/; /** * Validates that the given value is a valid filter type */ function validateFilterType(maybeFilterType: any): maybeFilterType is SplitIO.SplitFilterType { return FILTERS_METADATA.some(filterMetadata => filterMetadata.type === maybeFilterType); } /** * Validate, deduplicate and sort a given list of filter values. * * @param type - filter type string used for log messages * @param values - list of values to validate, deduplicate and sort * @param maxLength - max length allowed for the list of values * @returns list of valid, unique and alphabetically sorted non-empty strings. The list is empty if `values` param is not a non-empty array or all its values are invalid. * * @throws Error if the sanitized list exceeds the length indicated by `maxLength` */ function validateSplitFilter(log: ILogger, type: SplitIO.SplitFilterType, values: string[], maxLength: number) { // validate and remove invalid and duplicated values let result = validateSplits(log, values, LOG_PREFIX_SETTINGS, `${type} filter`, `${type} filter value`); if (result) { if (type === 'bySet') { result = sanitizeFlagSets(log, result, LOG_PREFIX_SETTINGS); } // check max length if (result.length > maxLength) throw new Error(`${maxLength} unique values can be specified at most for '${type}' filter. You passed ${result.length}. Please consider reducing the amount or using other filter.`); // sort values result.sort(); } return result || []; // returns empty array if `result` is `false` } /** * Returns a string representing the URL encoded query component of /splitChanges URL. * * @param groupedFilters - object of filters. Each filter must be a list of valid, unique and ordered string values. * @returns null or string with the `split filter query` component of the URL. */ function queryStringBuilder(groupedFilters: Record) { const queryParams: string[] = []; FILTERS_METADATA.forEach(({ type, queryParam }) => { const filter = groupedFilters[type]; if (filter.length > 0) queryParams.push(queryParam + filter.map(value => encodeURIComponent(value)).join(',')); }); return queryParams.length > 0 ? '&' + queryParams.join('&') : null; } /** * Sanitizes set names list taking into account: * - It should be lowercase * - Must adhere the following regular expression `/^[a-z0-9][_a-z0-9]{0,49}$/` that means * - must start with a letter or number * - Be in lowercase * - Be alphanumeric * - have a max length of 50 characters * * @returns sanitized list of set names */ function sanitizeFlagSets(log: ILogger, flagSets: string[], method: string) { let sanitizedSets = flagSets .map(flagSet => { if (CAPITAL_LETTERS_REGEX.test(flagSet)) { log.warn(WARN_LOWERCASE_FLAGSET, [method, flagSet]); flagSet = flagSet.toLowerCase(); } return flagSet; }) .filter(flagSet => { if (!VALID_FLAGSET_REGEX.test(flagSet)) { log.warn(WARN_INVALID_FLAGSET, [method, flagSet, VALID_FLAGSET_REGEX, flagSet]); return false; } if (typeof flagSet !== 'string') return false; return true; }); return uniq(sanitizedSets); } function configuredFilter(validFilters: SplitIO.SplitFilter[], filterType: SplitIO.SplitFilterType) { return find(validFilters, (filter: SplitIO.SplitFilter) => filter.type === filterType && filter.values.length > 0); } /** * Validates `splitFilters` configuration object and parses it into a query string for filtering splits on `/splitChanges` fetch. * * @param log - logger * @param maybeSplitFilters - split filters configuration param provided by the user * @param mode - settings mode * @returns it returns an object with the following properties: * - `validFilters`: the validated `splitFilters` configuration object defined by the user. * - `queryString`: the parsed split filter query. it is null if all filters are invalid or all values in filters are invalid. * - `groupedFilters`: list of values grouped by filter type. * * @throws Error if the some of the grouped list of values per filter exceeds the max allowed length */ export function validateSplitFilters(log: ILogger, maybeSplitFilters: any, mode: string): ISplitFiltersValidation { // Validation result schema const res = { validFilters: [], queryString: null, groupedFilters: { bySet: [], byName: [], byPrefix: [] }, } as ISplitFiltersValidation; // do nothing if `splitFilters` param is not a non-empty array or mode is not STANDALONE if (!maybeSplitFilters) return res; // Warn depending on the mode if (isConsumerMode(mode)) { log.warn(WARN_SPLITS_FILTER_IGNORED); return res; } // Check collection type if (!Array.isArray(maybeSplitFilters) || maybeSplitFilters.length === 0) { log.warn(WARN_SPLITS_FILTER_EMPTY); return res; } // Validate filters and group their values by filter type inside `groupedFilters` object res.validFilters = maybeSplitFilters.filter((filter, index) => { if (filter && validateFilterType(filter.type) && Array.isArray(filter.values)) { res.groupedFilters[filter.type as SplitIO.SplitFilterType] = res.groupedFilters[filter.type as SplitIO.SplitFilterType].concat(filter.values); return true; } else { log.warn(WARN_SPLITS_FILTER_INVALID, [index]); } return false; }); // By filter type, remove invalid and duplicated values and order them FILTERS_METADATA.forEach(({ type, maxLength }) => { if (res.groupedFilters[type].length > 0) res.groupedFilters[type] = validateSplitFilter(log, type, res.groupedFilters[type], maxLength); }); const setFilter = configuredFilter(res.validFilters, 'bySet'); // Clean all filters if set filter is present if (setFilter) { if (configuredFilter(res.validFilters, 'byName') || configuredFilter(res.validFilters, 'byPrefix')) log.error(ERROR_SETS_FILTER_EXCLUSIVE); objectAssign(res.groupedFilters, { byName: [], byPrefix: [] }); } // build query string res.queryString = queryStringBuilder(res.groupedFilters); log.debug(SETTINGS_SPLITS_FILTER, [res.queryString]); return res; } export function validateFlagSets(log: ILogger, method: string, flagSets: string[], flagSetsInConfig: string[]): string[] | false { const sets = validateSplits(log, flagSets, method, 'flag sets', 'flag set'); let toReturn = sets ? sanitizeFlagSets(log, sets, method) : []; if (flagSetsInConfig.length > 0) { toReturn = toReturn.filter(flagSet => { if (flagSetsInConfig.indexOf(flagSet) > -1) { return true; } log.warn(WARN_FLAGSET_NOT_CONFIGURED, [method, flagSet]); return false; }); } return toReturn.length ? toReturn : false; }