Skip to content
3 changes: 2 additions & 1 deletion src/dtos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -231,7 +232,7 @@ export interface ISplit {
trafficAllocation?: number,
trafficAllocationSeed?: number
configurations?: {
[treatmentName: string]: string
[treatmentName: string]: string | SplitIO.JsonObject
},
sets?: string[],
impressionsDisabled?: boolean
Expand Down
2 changes: 1 addition & 1 deletion src/evaluator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion src/sdkClient/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
if (withConfig) {
return {
treatment,
config
config: config as string | null
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/sdkManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 17 additions & 7 deletions src/services/__tests__/splitApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
7 changes: 6 additions & 1 deletion src/services/splitApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } };
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 3 additions & 2 deletions src/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyT

export type IFetchAuth = (userKeys?: string[]) => Promise<IResponse>

export type IFetchSplitChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise<IResponse>
export type IFetchDefinitionChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise<IResponse>

export type IFetchSegmentChanges = (since: number, segmentName: string, noCache?: boolean, till?: number) => Promise<IResponse>

Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/sync/polling/fetchers/__tests__/configsFetcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ISplitChangesResponse } from '../../../../dtos/types';
import { convertConfigsResponseToDefinitionChangesResponse, IConfigsResponse } from '../configsFetcher';

// TODO: complete input and output mocks
const inputMock: 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 expectedOutput: 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(inputMock);
expect(result).toEqual(expectedOutput);
});

});
141 changes: 141 additions & 0 deletions src/sync/polling/fetchers/configsFetcher.ts
Original file line number Diff line number Diff line change
@@ -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<IConfigMatcher>;
}>
};
}

/** 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<IResponse>) => Promise<IResponse>
): Promise<ISplitChangesResponse> {

let configsPromise = fetchConfigs(since, noCache, till, rbSince);
if (decorator) configsPromise = decorator(configsPromise);

return configsPromise
.then<IConfigsResponse>((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<string, SplitIO.JsonObject> = {};
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),
},
};
}
4 changes: 2 additions & 2 deletions src/sync/polling/fetchers/splitChangesFetcher.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<IStorageBase, 'splits' | 'rbSegments'>): ISplitChangesFetcher {
export function splitChangesFetcherFactory(fetchSplitChanges: IFetchDefinitionChanges, settings: ISettings, storage: Pick<IStorageBase, 'splits' | 'rbSegments'>): ISplitChangesFetcher {

const log = settings.log;
const PROXY_CHECK_INTERVAL_MILLIS = checkIfServerSide(settings) ? PROXY_CHECK_INTERVAL_MILLIS_SS : PROXY_CHECK_INTERVAL_MILLIS_CS;
Expand Down
4 changes: 2 additions & 2 deletions src/sync/polling/syncTasks/splitsSyncTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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';

/**
* Creates a sync task that periodically executes a `splitChangesUpdater` task
*/
export function splitsSyncTaskFactory(
fetchSplitChanges: IFetchSplitChanges,
fetchSplitChanges: IFetchDefinitionChanges,
storage: IStorageSync,
readiness: IReadinessManager,
settings: ISettings,
Expand Down
3 changes: 2 additions & 1 deletion src/sync/submitters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<OperationType, number | undefined>>
export type HttpErrors = Partial<Record<OperationType, { [statusCode: string]: number }>>
Expand Down
Loading