diff --git a/src/dtos/types.ts b/src/dtos/types.ts index 2b0ee4ef..f03d142d 100644 --- a/src/dtos/types.ts +++ b/src/dtos/types.ts @@ -215,6 +215,7 @@ export interface IRBSegment { } | null } +// @TODO: rename to IDefinition (Configs and Feature Flags are definitions) export interface ISplit { name: string, changeNumber: number, @@ -231,7 +232,7 @@ export interface ISplit { trafficAllocation?: number, trafficAllocationSeed?: number configurations?: { - [treatmentName: string]: string + [treatmentName: string]: string | SplitIO.JsonObject }, sets?: string[], impressionsDisabled?: boolean diff --git a/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts new file mode 100644 index 00000000..239185fb --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,50 @@ +import { FallbackConfigsCalculator } from '../'; +import SplitIO from '../../../../types/splitio'; +import { CONTROL } from '../../../utils/constants'; + +describe('FallbackConfigsCalculator', () => { + test('returns specific fallback if config name exists', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: { + 'configA': { variant: 'VARIANT_A', value: { key: 1 } }, + }, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('configA', 'label by name'); + + expect(result).toEqual({ + treatment: 'VARIANT_A', + config: { key: 1 }, + label: 'fallback - label by name', + }); + }); + + test('returns global fallback if config name is missing and global exists', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: {}, + global: { variant: 'GLOBAL_VARIANT', value: { global: true } }, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('missingConfig', 'label by global'); + + expect(result).toEqual({ + treatment: 'GLOBAL_VARIANT', + config: { global: true }, + label: 'fallback - label by global', + }); + }); + + test('returns control fallback if config name and global are missing', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: {}, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('missingConfig', 'label by noFallback'); + + expect(result).toEqual({ + treatment: CONTROL, + config: null, + label: 'label by noFallback', + }); + }); +}); diff --git a/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..12e5807b --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,147 @@ +import { isValidConfigName, isValidConfig, sanitizeFallbacks } from '../fallbackSanitizer'; +import SplitIO from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + +describe('FallbackConfigsSanitizer', () => { + const validConfig: SplitIO.Config = { variant: 'on', value: { color: 'blue' } }; + const invalidVariantConfig: SplitIO.Config = { variant: ' ', value: { color: 'blue' } }; + const invalidValueConfig = { variant: 'on', value: 'not_an_object' } as unknown as SplitIO.Config; + const fallbackMock = { + global: undefined, + byName: {} + }; + + beforeEach(() => { + loggerMock.mockClear(); + }); + + describe('isValidConfigName', () => { + test('returns true for a valid config name', () => { + expect(isValidConfigName('my_config')).toBe(true); + }); + + test('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect(isValidConfigName(longName)).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + expect(isValidConfigName('invalid config')).toBe(false); + }); + + test('returns false if the name is not a string', () => { + // @ts-ignore + expect(isValidConfigName(true)).toBe(false); + }); + }); + + describe('isValidConfig', () => { + test('returns true for a valid config', () => { + expect(isValidConfig(validConfig)).toBe(true); + }); + + test('returns false for null or undefined', () => { + expect(isValidConfig()).toBe(false); + expect(isValidConfig(undefined)).toBe(false); + }); + + test('returns false for a variant longer than 100 chars', () => { + const long: SplitIO.Config = { variant: 'a'.repeat(101), value: {} }; + expect(isValidConfig(long)).toBe(false); + }); + + test('returns false if variant does not match regex pattern', () => { + const invalid: SplitIO.Config = { variant: 'invalid variant!', value: {} }; + expect(isValidConfig(invalid)).toBe(false); + }); + + test('returns false if value is not an object', () => { + expect(isValidConfig(invalidValueConfig)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + test('returns the config if valid', () => { + expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validConfig })).toEqual({ ...fallbackMock, global: validConfig }); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + test('returns undefined and logs error if variant is invalid', () => { + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidVariantConfig }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback configs - Discarded fallback') + ); + }); + + test('returns undefined and logs error if value is invalid', () => { + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidValueConfig }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback configs - Discarded fallback') + ); + }); + }); + + describe('sanitizeByName', () => { + test('returns a sanitized map with valid entries only', () => { + const input = { + valid_config: validConfig, + 'invalid config': validConfig, + bad_variant: invalidVariantConfig, + }; + + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input }); + + expect(result).toEqual({ ...fallbackMock, byName: { valid_config: validConfig } }); + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid config name + bad_variant + }); + + test('returns empty object if all invalid', () => { + const input = { + 'invalid config': invalidVariantConfig, + }; + + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test('returns same object if all valid', () => { + const input = { + ...fallbackMock, + byName: { + config_one: validConfig, + config_two: { variant: 'valid_2', value: { key: 'val' } }, + } + }; + + const result = sanitizeFallbacks(loggerMock, input); + expect(result).toEqual(input); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + }); + + describe('sanitizeFallbacks', () => { + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks'); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties' + ); + }); + + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, true); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties' + ); + }); + + test('sanitizes both global and byName fallbacks for empty object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, { global: {} }); + expect(result).toEqual({ global: undefined, byName: {} }); + }); + }); +}); diff --git a/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..d9f9ebb7 --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,78 @@ +import SplitIO from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; + +enum FallbackDiscardReason { + ConfigName = 'Invalid config name (max 100 chars, no spaces)', + Variant = 'Invalid variant (max 100 chars and must match pattern)', + Value = 'Invalid value (must be an object)', +} + +const VARIANT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + +export function isValidConfigName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); +} + +export function isValidConfig(config?: SplitIO.Config): boolean { + if (!isObject(config)) return false; + if (!isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) return false; + if (!isObject(config!.value)) return false; + return true; +} + +function sanitizeGlobal(logger: ILogger, config?: SplitIO.Config): SplitIO.Config | undefined { + if (config === undefined) return undefined; + if (!isValidConfig(config)) { + if (!isObject(config) || !isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) { + logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Variant}`); + } else { + logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Value}`); + } + return undefined; + } + return config; +} + +function sanitizeByName( + logger: ILogger, + byNameFallbacks?: Record +): Record { + const sanitizedByName: Record = {}; + + if (!isObject(byNameFallbacks)) return sanitizedByName; + + Object.keys(byNameFallbacks!).forEach((configName) => { + const config = byNameFallbacks![configName]; + + if (!isValidConfigName(configName)) { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.ConfigName}`); + return; + } + + if (!isValidConfig(config)) { + if (!isObject(config) || !isString(config.variant) || config.variant.length > 100 || !VARIANT_PATTERN.test(config.variant)) { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Variant}`); + } else { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Value}`); + } + return; + } + + sanitizedByName[configName] = config; + }); + + return sanitizedByName; +} + +export function sanitizeFallbacks(logger: ILogger, fallbacks: SplitIO.FallbackConfigs): SplitIO.FallbackConfigs | undefined { + if (!isObject(fallbacks)) { + logger.error('Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'); + return; + } + + return { + global: sanitizeGlobal(logger, fallbacks.global), + byName: sanitizeByName(logger, fallbacks.byName) + }; +} diff --git a/src/evaluator/fallbackConfigsCalculator/index.ts b/src/evaluator/fallbackConfigsCalculator/index.ts new file mode 100644 index 00000000..fa80e9bd --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/index.ts @@ -0,0 +1,24 @@ +import { IFallbackCalculator } from '../fallbackTreatmentsCalculator/index'; +import { CONTROL } from '../../utils/constants'; +import SplitIO from '../../../types/splitio'; + +export const FALLBACK_PREFIX = 'fallback - '; + +export function FallbackConfigsCalculator(fallbacks: SplitIO.FallbackConfigs = {}): IFallbackCalculator { + + return (configName: string, label = '') => { + const fallback = fallbacks.byName?.[configName] || fallbacks.global; + + return fallback ? + { + treatment: fallback.variant, + config: fallback.value, + label: `${FALLBACK_PREFIX}${label}`, + } : + { + treatment: CONTROL, + config: null, + label, + }; + }; +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 5c2b4663..fb213d25 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,12 +1,16 @@ -import { FallbackTreatmentConfiguration, TreatmentWithConfig } from '../../../types/splitio'; import { CONTROL } from '../../utils/constants'; import { isString } from '../../utils/lang'; +import SplitIO from '../../../types/splitio'; -export type IFallbackTreatmentsCalculator = (flagName: string, label?: string) => TreatmentWithConfig & { label: string }; +export type IFallbackCalculator = (definitionName: string, label?: string) => { + treatment: string; + config: string | null | SplitIO.JsonObject; + label: string +}; export const FALLBACK_PREFIX = 'fallback - '; -export function FallbackTreatmentsCalculator(fallbacks: FallbackTreatmentConfiguration = {}): IFallbackTreatmentsCalculator { +export function FallbackTreatmentsCalculator(fallbacks: SplitIO.FallbackTreatmentConfiguration = {}): IFallbackCalculator { return (flagName: string, label = '') => { const fallback = fallbacks.byFlag?.[flagName] || fallbacks.global; diff --git a/src/evaluator/types.ts b/src/evaluator/types.ts index 42900f06..ea976090 100644 --- a/src/evaluator/types.ts +++ b/src/evaluator/types.ts @@ -22,7 +22,7 @@ export interface IEvaluation { treatment?: string, label: string, changeNumber?: number, - config?: string | null + config?: string | null | SplitIO.JsonObject } export type IEvaluationResult = IEvaluation & { treatment: string; impressionsDisabled?: boolean } diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index e4de8f28..3f87782b 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,7 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; -import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -14,7 +14,7 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); -const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = FallbackTreatmentsCalculator(); +const fallbackTreatmentsCalculator = FallbackTreatmentsCalculator(); const readinessManager: any = { isReadyFromCache: () => true, diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 6eded6c3..19f0c7dd 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) { * Creator of base client with getTreatments and track methods. */ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackCalculator } = params; const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -147,7 +147,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl let { treatment, label, config = null } = evaluation; if (treatment === CONTROL) { - const fallbackTreatment = fallbackTreatmentsCalculator(featureFlagName, label); + const fallbackTreatment = fallbackCalculator(featureFlagName, label); treatment = fallbackTreatment.treatment; label = fallbackTreatment.label; config = fallbackTreatment.config; @@ -173,7 +173,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl if (withConfig) { return { treatment, - config + config: config as string | null }; } diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 9ed2a722..e8db5b0b 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -19,13 +19,13 @@ import { ISettings } from '../types'; import SplitIO from '../../types/splitio'; import { isConsumerMode } from '../utils/settingsValidation/mode'; import { validateFlagSets } from '../utils/settingsValidation/splitFilters'; -import { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; +import { IFallbackCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Decorator that validates the input before actually executing the client methods. * We should "guard" the client here, while not polluting the "real" implementation of those methods. */ -export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator): TClient { +export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackCalculator: IFallbackCalculator): TClient { const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -66,7 +66,7 @@ export function clientInputValidationDecorator, - fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator + fallbackCalculator: IFallbackCalculator } export interface ISdkFactoryContextSync extends ISdkFactoryContext { diff --git a/src/sdkManager/index.ts b/src/sdkManager/index.ts index 5260170c..4abc5a44 100644 --- a/src/sdkManager/index.ts +++ b/src/sdkManager/index.ts @@ -29,7 +29,7 @@ function objectToView(splitObject: ISplit | null): SplitIO.SplitView | null { killed: splitObject.killed, changeNumber: splitObject.changeNumber || 0, treatments: collectTreatments(splitObject), - configs: splitObject.configurations || {}, + configs: splitObject.configurations as SplitIO.SplitView['configs'] || {}, sets: splitObject.sets || [], defaultTreatment: splitObject.defaultTreatment, impressionsDisabled: splitObject.impressionsDisabled === true, diff --git a/src/services/__tests__/splitApi.spec.ts b/src/services/__tests__/splitApi.spec.ts index 196266a3..c2f63500 100644 --- a/src/services/__tests__/splitApi.spec.ts +++ b/src/services/__tests__/splitApi.spec.ts @@ -45,22 +45,27 @@ describe('splitApi', () => { assertHeaders(settings, headers); expect(url).toBe(expectedFlagsUrl(-1, 100, settings.validateFilters || false, settings, -1)); + splitApi.fetchConfigs(-1, false, 100, -1); + [url, { headers }] = fetchMock.mock.calls[4]; + assertHeaders(settings, headers); + expect(url).toBe(expectedConfigsUrl(-1, 100, settings.validateFilters || false, settings, -1)); + splitApi.postEventsBulk('fake-body'); - assertHeaders(settings, fetchMock.mock.calls[4][1].headers); + assertHeaders(settings, fetchMock.mock.calls[5][1].headers); splitApi.postTestImpressionsBulk('fake-body'); - assertHeaders(settings, fetchMock.mock.calls[5][1].headers); - expect(fetchMock.mock.calls[5][1].headers['SplitSDKImpressionsMode']).toBe(settings.sync.impressionsMode); + assertHeaders(settings, fetchMock.mock.calls[6][1].headers); + expect(fetchMock.mock.calls[6][1].headers['SplitSDKImpressionsMode']).toBe(settings.sync.impressionsMode); splitApi.postTestImpressionsCount('fake-body'); - assertHeaders(settings, fetchMock.mock.calls[6][1].headers); + assertHeaders(settings, fetchMock.mock.calls[7][1].headers); splitApi.postMetricsConfig('fake-body'); - assertHeaders(settings, fetchMock.mock.calls[7][1].headers); - splitApi.postMetricsUsage('fake-body'); assertHeaders(settings, fetchMock.mock.calls[8][1].headers); + splitApi.postMetricsUsage('fake-body'); + assertHeaders(settings, fetchMock.mock.calls[9][1].headers); - expect(telemetryTrackerMock.trackHttp).toBeCalledTimes(9); + expect(telemetryTrackerMock.trackHttp).toBeCalledTimes(10); telemetryTrackerMock.trackHttp.mockClear(); fetchMock.mockClear(); @@ -70,6 +75,11 @@ describe('splitApi', () => { const filterQueryString = settings.sync.__splitFiltersValidation && settings.sync.__splitFiltersValidation.queryString; return `sdk/splitChanges?s=1.1&since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${usesFilter ? filterQueryString : ''}${till ? '&till=' + till : ''}`; } + + function expectedConfigsUrl(since: number, till: number, usesFilter: boolean, settings: ISettings, rbSince?: number) { + const filterQueryString = settings.sync.__splitFiltersValidation && settings.sync.__splitFiltersValidation.queryString; + return `sdk/configs?${settings.sync.flagSpecVersion ? `s=${settings.sync.flagSpecVersion}&` : ''}since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${usesFilter ? filterQueryString : ''}${till ? '&till=' + till : ''}`; + } }); test('rejects requests if fetch Api is not provided', (done) => { diff --git a/src/services/splitApi.ts b/src/services/splitApi.ts index 6860b022..67d7834f 100644 --- a/src/services/splitApi.ts +++ b/src/services/splitApi.ts @@ -4,7 +4,7 @@ import { splitHttpClientFactory } from './splitHttpClient'; import { ISplitApi } from './types'; import { objectAssign } from '../utils/lang/objectAssign'; import { ITelemetryTracker } from '../trackers/types'; -import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants'; +import { SPLITS, CONFIGS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants'; import { ERROR_TOO_MANY_SETS } from '../logger/constants'; const noCacheHeaderOptions = { headers: { 'Cache-Control': 'no-cache' } }; @@ -61,6 +61,11 @@ export function splitApiFactory( }); }, + fetchConfigs(since: number, noCache?: boolean, till?: number, rbSince?: number) { + const url = `${urls.sdk}/configs?${settings.sync.flagSpecVersion ? `s=${settings.sync.flagSpecVersion}&` : ''}since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${filterQueryString || ''}${till ? '&till=' + till : ''}`; + return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(CONFIGS)); + }, + fetchSegmentChanges(since: number, segmentName: string, noCache?: boolean, till?: number) { const url = `${urls.sdk}/segmentChanges/${segmentName}?since=${since}${till ? '&till=' + till : ''}`; return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined, telemetryTracker.trackHttp(SEGMENT)); diff --git a/src/services/types.ts b/src/services/types.ts index b747dbb5..01595280 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -35,7 +35,7 @@ export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyT export type IFetchAuth = (userKeys?: string[]) => Promise -export type IFetchSplitChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise +export type IFetchDefinitionChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise export type IFetchSegmentChanges = (since: number, segmentName: string, noCache?: boolean, till?: number) => Promise @@ -59,7 +59,8 @@ export interface ISplitApi { getSdkAPIHealthCheck: IHealthCheckAPI getEventsAPIHealthCheck: IHealthCheckAPI fetchAuth: IFetchAuth - fetchSplitChanges: IFetchSplitChanges + fetchSplitChanges: IFetchDefinitionChanges + fetchConfigs: IFetchDefinitionChanges fetchSegmentChanges: IFetchSegmentChanges fetchMemberships: IFetchMemberships postEventsBulk: IPostEventsBulk diff --git a/src/sync/polling/fetchers/__tests__/configsFetcher.spec.ts b/src/sync/polling/fetchers/__tests__/configsFetcher.spec.ts new file mode 100644 index 00000000..c3117786 --- /dev/null +++ b/src/sync/polling/fetchers/__tests__/configsFetcher.spec.ts @@ -0,0 +1,74 @@ +import { ISplitChangesResponse } from '../../../../dtos/types'; +import { convertConfigsResponseToDefinitionChangesResponse, IConfigsResponse } from '../configsFetcher'; + +const INPUT: IConfigsResponse = { + s: 100, + t: 200, + d: [{ 'name': 'SomeConfig1', 'defaultVariant': 'v2', 'variants': [{ 'name': 'v1', 'definition': { 'prop1': true, 'prop2': 123 } }, { 'name': 'v2', 'definition': { 'prop1': false, 'prop2': 456 } }], 'targeting': { 'conditions': [{ 'variant': 'v1', 'label': 'main condition', 'matchers': [{ 'type': 'IS_EQUAL_TO', 'data': { 'type': 'NUMBER', 'number': 42 }, 'attribute': 'age' }, { 'type': 'WHITELIST', 'data': { 'strings': ['a', 'b', 'c'] }, 'attribute': 'favoriteCharacter' }] }] } }], +}; + +const EXPECTED_OUTPUT: ISplitChangesResponse = { + ff: { + s: 100, + t: 200, + d: [{ + name: 'SomeConfig1', + changeNumber: 0, + status: 'ACTIVE', + killed: false, + defaultTreatment: 'v2', + trafficTypeName: 'user', + seed: 0, + configurations: { + 'v1': { 'prop1': true, 'prop2': 123 }, + 'v2': { 'prop1': false, 'prop2': 456 }, + }, + conditions: [ + { + conditionType: 'WHITELIST', + label: 'main condition', + matcherGroup: { + combiner: 'AND', + matchers: [ + { + matcherType: 'EQUAL_TO', + negate: false, + keySelector: { trafficType: 'user', attribute: 'age' }, + unaryNumericMatcherData: { dataType: 'NUMBER', value: 42 }, + }, + { + matcherType: 'WHITELIST', + negate: false, + keySelector: { trafficType: 'user', attribute: 'favoriteCharacter' }, + whitelistMatcherData: { whitelist: ['a', 'b', 'c'] }, + }, + ], + }, + partitions: [{ treatment: 'v1', size: 100 }], + }, + { + conditionType: 'ROLLOUT', + matcherGroup: { + combiner: 'AND', + matchers: [{ + keySelector: null, + matcherType: 'ALL_KEYS', + negate: false, + }], + }, + partitions: [{ treatment: 'v2', size: 100 }], + label: 'default rule', + }, + ], + }], + }, +}; + +describe('convertConfigsResponseToDefinitionChangesResponse', () => { + + test('should convert a configs response to a definition changes response', () => { + const result = convertConfigsResponseToDefinitionChangesResponse(INPUT); + expect(result).toEqual(EXPECTED_OUTPUT); + }); + +}); diff --git a/src/sync/polling/fetchers/configsFetcher.ts b/src/sync/polling/fetchers/configsFetcher.ts new file mode 100644 index 00000000..29aa6887 --- /dev/null +++ b/src/sync/polling/fetchers/configsFetcher.ts @@ -0,0 +1,141 @@ +import { ISplit, ISplitChangesResponse, ISplitCondition, ISplitMatcher } from '../../../dtos/types'; +import { IFetchDefinitionChanges, IResponse } from '../../../services/types'; +import { ISplitChangesFetcher } from './types'; +import SplitIO from '../../../../types/splitio'; + +type IConfigMatcher = { + type: 'IS_EQUAL_TO'; + data: { type: 'NUMBER'; number: number }; + attribute?: string; +} | { + type: 'WHITELIST'; + data: { strings: string[] }; + attribute?: string; +} + +type IConfig = { + name: string; + variants: Array<{ + name: string; + definition: SplitIO.JsonObject; + }>; + defaultVariant: string; + changeNumber?: number; + targeting?: { + conditions?: Array<{ + variant: string; + label: string; + matchers: Array; + }> + }; +} + +/** Interface of the parsed JSON response of `/configs` */ +export type IConfigsResponse = { + t: number, + s?: number, + d: IConfig[] +} + +/** + * Factory of Configs fetcher. + * Configs fetcher is a wrapper around `configs` API service that parses the response and handle errors. + */ +export function configsFetcherFactory(fetchConfigs: IFetchDefinitionChanges): ISplitChangesFetcher { + + return function configsFetcher( + since: number, + noCache?: boolean, + till?: number, + rbSince?: number, + // Optional decorator for `fetchConfigs` promise, such as timeout or time tracker + decorator?: (promise: Promise) => Promise + ): Promise { + + let configsPromise = fetchConfigs(since, noCache, till, rbSince); + if (decorator) configsPromise = decorator(configsPromise); + + return configsPromise + .then((resp: IResponse) => resp.json()) + .then(convertConfigsResponseToDefinitionChangesResponse); + }; + +} + +function defaultCondition(treatment: string): ISplitCondition { + return { + conditionType: 'ROLLOUT', + matcherGroup: { + combiner: 'AND', + matchers: [{ + keySelector: null, + matcherType: 'ALL_KEYS', + negate: false + }], + }, + partitions: [{ treatment, size: 100 }], + label: 'default rule', + }; +} + +function convertMatcher(matcher: IConfigMatcher): ISplitMatcher { + const keySelector = matcher.attribute ? { trafficType: 'user', attribute: matcher.attribute } : null; + + switch (matcher.type) { + case 'IS_EQUAL_TO': + return { + matcherType: 'EQUAL_TO', + negate: false, + keySelector, + unaryNumericMatcherData: { dataType: matcher.data.type, value: matcher.data.number }, + }; + case 'WHITELIST': + return { + matcherType: 'WHITELIST', + negate: false, + keySelector, + whitelistMatcherData: { whitelist: matcher.data.strings }, + }; + } +} + +function convertConfigToDefinition(config: IConfig): ISplit { + const defaultTreatment = config.defaultVariant || (config.variants && config.variants[0]?.name) || 'control'; + + const configurations: Record = {}; + config.variants.forEach(variant => configurations[variant.name] = variant.definition); + + const conditions: ISplitCondition[] = config.targeting?.conditions?.map(condition => ({ + conditionType: condition.matchers.some((m: IConfigMatcher) => m.type === 'WHITELIST') ? 'WHITELIST' : 'ROLLOUT', + label: condition.label, + matcherGroup: { + combiner: 'AND', + matchers: condition.matchers.map(convertMatcher), + }, + partitions: [{ treatment: condition.variant, size: 100 }], + })) || []; + + conditions.push(defaultCondition(defaultTreatment)); + + return { + name: config.name, + changeNumber: config.changeNumber || 0, + status: 'ACTIVE', + conditions, + killed: false, + defaultTreatment, + trafficTypeName: 'user', + seed: 0, + configurations, + }; +} + +export function convertConfigsResponseToDefinitionChangesResponse(configs: IConfigsResponse): ISplitChangesResponse { + return { + ff: { + s: configs.s, + t: configs.t, + d: configs.d.map(convertConfigToDefinition), + }, + }; +} diff --git a/src/sync/polling/fetchers/splitChangesFetcher.ts b/src/sync/polling/fetchers/splitChangesFetcher.ts index 7d133ba2..422beadf 100644 --- a/src/sync/polling/fetchers/splitChangesFetcher.ts +++ b/src/sync/polling/fetchers/splitChangesFetcher.ts @@ -1,6 +1,6 @@ import { ISettings } from '../../../types'; import { ISplitChangesResponse } from '../../../dtos/types'; -import { IFetchSplitChanges, IResponse } from '../../../services/types'; +import { IFetchDefinitionChanges, IResponse } from '../../../services/types'; import { IStorageBase } from '../../../storages/types'; import { FLAG_SPEC_VERSION } from '../../../utils/constants'; import { base } from '../../../utils/settingsValidation'; @@ -20,7 +20,7 @@ function sdkEndpointOverridden(settings: ISettings) { * SplitChanges fetcher is a wrapper around `splitChanges` API service that parses the response and handle errors. */ // @TODO breaking: drop support for Split Proxy below v5.10.0 and simplify the implementation -export function splitChangesFetcherFactory(fetchSplitChanges: IFetchSplitChanges, settings: ISettings, storage: Pick): ISplitChangesFetcher { +export function splitChangesFetcherFactory(fetchSplitChanges: IFetchDefinitionChanges, settings: ISettings, storage: Pick): ISplitChangesFetcher { const log = settings.log; const PROXY_CHECK_INTERVAL_MILLIS = checkIfServerSide(settings) ? PROXY_CHECK_INTERVAL_MILLIS_SS : PROXY_CHECK_INTERVAL_MILLIS_CS; diff --git a/src/sync/polling/syncTasks/splitsSyncTask.ts b/src/sync/polling/syncTasks/splitsSyncTask.ts index d385bf77..3cd9ecbf 100644 --- a/src/sync/polling/syncTasks/splitsSyncTask.ts +++ b/src/sync/polling/syncTasks/splitsSyncTask.ts @@ -3,7 +3,7 @@ import { IReadinessManager } from '../../../readiness/types'; import { syncTaskFactory } from '../../syncTask'; import { ISplitsSyncTask } from '../types'; import { splitChangesFetcherFactory } from '../fetchers/splitChangesFetcher'; -import { IFetchSplitChanges } from '../../../services/types'; +import { IFetchDefinitionChanges } from '../../../services/types'; import { ISettings } from '../../../types'; import { splitChangesUpdaterFactory } from '../updaters/splitChangesUpdater'; @@ -11,7 +11,7 @@ import { splitChangesUpdaterFactory } from '../updaters/splitChangesUpdater'; * Creates a sync task that periodically executes a `splitChangesUpdater` task */ export function splitsSyncTaskFactory( - fetchSplitChanges: IFetchSplitChanges, + fetchSplitChanges: IFetchDefinitionChanges, storage: IStorageSync, readiness: IReadinessManager, settings: ISettings, diff --git a/src/sync/submitters/types.ts b/src/sync/submitters/types.ts index 36a76c9b..57e8bfd5 100644 --- a/src/sync/submitters/types.ts +++ b/src/sync/submitters/types.ts @@ -102,7 +102,8 @@ export type TELEMETRY = 'te'; export type TOKEN = 'to'; export type SEGMENT = 'se'; export type MEMBERSHIPS = 'ms'; -export type OperationType = SPLITS | IMPRESSIONS | IMPRESSIONS_COUNT | EVENTS | TELEMETRY | TOKEN | SEGMENT | MEMBERSHIPS; +export type CONFIGS = 'cf'; +export type OperationType = SPLITS | IMPRESSIONS | IMPRESSIONS_COUNT | EVENTS | TELEMETRY | TOKEN | SEGMENT | MEMBERSHIPS | CONFIGS; export type LastSync = Partial> export type HttpErrors = Partial> diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index 6686c68e..b9c8edc1 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -75,6 +75,7 @@ export const TELEMETRY = 'te'; export const TOKEN = 'to'; export const SEGMENT = 'se'; export const MEMBERSHIPS = 'ms'; +export const CONFIGS = 'cf'; export const TREATMENT = 't'; export const TREATMENTS = 'ts'; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index b8753566..5afb1daa 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -509,7 +509,7 @@ declare namespace SplitIO { /** * Metadata type for SDK update events. */ - type SdkUpdateMetadataType = 'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'; + type SdkUpdateMetadataType = 'CONFIGS_UPDATE' | 'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'; /** * Metadata for the ready events emitted when the SDK is ready to evaluate feature flags. @@ -2284,4 +2284,113 @@ declare namespace SplitIO { */ split(featureFlagName: string): SplitViewAsync; } + + /** + * Fallback configuration objects returned by the `client.getConfig` method when the SDK is not ready or the provided config name is not found. + */ + type FallbackConfigs = { + /** + * Fallback config for all config names. + */ + global?: Config; + /** + * Fallback configs for specific config names. It takes precedence over the global fallback config. + */ + byName?: { + [configName: string]: Config; + }; + } + + /** + * Configs SDK settings. + */ + interface ConfigsClientSettings { + /** + * Your SDK key. + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} + */ + authorizationKey: string; + /** + * Configs definitions refresh rate for polling, in seconds. + * + * @defaultValue `60` + */ + configsRefreshRate?: number; + /** + * Logging level. + * + * @defaultValue `'NONE'` + */ + logLevel?: LogLevel; + /** + * Time in seconds until SDK ready timeout is emitted. + * + * @defaultValue `10` + */ + timeout?: number; + /** + * Custom endpoints to replace the default ones used by the SDK. + */ + urls?: UrlSettings; + /** + * Fallback configuration objects returned by the `client.getConfig` method when the SDK is not ready or the provided config name is not found. + */ + fallbackConfigs?: FallbackConfigs; + } + + /** + * Target for a config evaluation. + */ + interface Target { + /** + * The key of the target. + */ + key: SplitKey; + /** + * The attributes of the target. + * + * @defaultValue `undefined` + */ + attributes?: Attributes; + } + + type JsonValue = string | number | boolean | null | JsonObject | JsonArray; + type JsonArray = JsonValue[]; + type JsonObject = { [key: string]: JsonValue; }; + + /** + * Config definition. + */ + interface Config { + /** + * The name of the variant. + */ + variant: string; + /** + * The config value, a raw JSON object. + */ + value: JsonObject; + } + + /** + * Configs SDK client interface. + */ + interface ConfigsClient extends IStatusInterface { + /** + * Destroys the client. + * + * @returns A promise that resolves once all clients are destroyed. + */ + destroy(): Promise; + /** + * Gets the config object for a given config name and optional target. If no target is provided, the default variant of the config is returned. + * + * @param name - The name of the config we want to get. + * @param target - The target of the config evaluation. + * @param options - An object of type EvaluationOptions for advanced evaluation options. + * @returns The config object. + */ + getConfig(name: string, target?: Target, options?: EvaluationOptions): Config; + } }