From 6caf55f2d809e209626a5b7ec6cf41bc64c48b85 Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Wed, 15 Jun 2022 14:35:55 -0700 Subject: [PATCH 1/6] Implement assignment logic --- docs/js-client-sdk.getinstance.md | 19 ++++ docs/js-client-sdk.iclientconfig.apikey.md | 13 +++ docs/js-client-sdk.iclientconfig.baseurl.md | 13 +++ docs/js-client-sdk.iclientconfig.md | 23 +++++ ...ent-sdk.iclientconfig.subjectattributes.md | 13 +++ .../js-client-sdk.iclientconfig.subjectkey.md | 13 +++ docs/js-client-sdk.init.md | 24 +++++ docs/js-client-sdk.md | 9 +- js-client-sdk.api.md | 16 +++- src/constants.ts | 2 + src/eppo-client.ts | 76 ++++++++++++++++ .../experiment-configuration-requestor.ts | 24 +++++ src/experiment/experiment-configuration.ts | 12 +++ src/experiment/rule.ts | 19 ++++ src/experiment/variation.ts | 9 ++ src/http-client.ts | 34 +++++++ src/index.ts | 89 ++++++++++++++++++- src/rule.ts | 19 ++++ src/rule_evaluator.ts | 59 ++++++++++++ src/sdk-data.ts | 5 ++ src/shard.ts | 15 ++++ src/storage.spec.ts | 37 ++++++++ src/storage.ts | 53 +++++++++++ src/validation.ts | 7 ++ 24 files changed, 598 insertions(+), 5 deletions(-) create mode 100644 docs/js-client-sdk.getinstance.md create mode 100644 docs/js-client-sdk.iclientconfig.apikey.md create mode 100644 docs/js-client-sdk.iclientconfig.baseurl.md create mode 100644 docs/js-client-sdk.iclientconfig.md create mode 100644 docs/js-client-sdk.iclientconfig.subjectattributes.md create mode 100644 docs/js-client-sdk.iclientconfig.subjectkey.md create mode 100644 docs/js-client-sdk.init.md create mode 100644 src/constants.ts create mode 100644 src/eppo-client.ts create mode 100644 src/experiment/experiment-configuration-requestor.ts create mode 100644 src/experiment/experiment-configuration.ts create mode 100644 src/experiment/rule.ts create mode 100644 src/experiment/variation.ts create mode 100644 src/http-client.ts create mode 100644 src/rule.ts create mode 100644 src/rule_evaluator.ts create mode 100644 src/sdk-data.ts create mode 100644 src/shard.ts create mode 100644 src/storage.spec.ts create mode 100644 src/storage.ts create mode 100644 src/validation.ts diff --git a/docs/js-client-sdk.getinstance.md b/docs/js-client-sdk.getinstance.md new file mode 100644 index 0000000..08d0f73 --- /dev/null +++ b/docs/js-client-sdk.getinstance.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [getInstance](./js-client-sdk.getinstance.md) + +## getInstance() function + +Used to access a singleton SDK client instance. Use the method after calling init() to initialize the client. + +Signature: + +```typescript +export declare function getInstance(): IEppoClient; +``` +Returns: + +IEppoClient + +a singleton client instance + diff --git a/docs/js-client-sdk.iclientconfig.apikey.md b/docs/js-client-sdk.iclientconfig.apikey.md new file mode 100644 index 0000000..6619bad --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.apikey.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [apiKey](./js-client-sdk.iclientconfig.apikey.md) + +## IClientConfig.apiKey property + +Eppo API key + +Signature: + +```typescript +apiKey: string; +``` diff --git a/docs/js-client-sdk.iclientconfig.baseurl.md b/docs/js-client-sdk.iclientconfig.baseurl.md new file mode 100644 index 0000000..93d875f --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.baseurl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [baseUrl](./js-client-sdk.iclientconfig.baseurl.md) + +## IClientConfig.baseUrl property + +Base URL of the Eppo API. Clients should use the default setting in most cases. + +Signature: + +```typescript +baseUrl?: string; +``` diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md new file mode 100644 index 0000000..7816df9 --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) + +## IClientConfig interface + +Configuration used for initializing the Eppo client + +Signature: + +```typescript +export interface IClientConfig +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [apiKey](./js-client-sdk.iclientconfig.apikey.md) | string | Eppo API key | +| [baseUrl?](./js-client-sdk.iclientconfig.baseurl.md) | string | (Optional) Base URL of the Eppo API. Clients should use the default setting in most cases. | +| [subjectAttributes?](./js-client-sdk.iclientconfig.subjectattributes.md) | Record<string, AttributeValueType> | (Optional) Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. | +| [subjectKey](./js-client-sdk.iclientconfig.subjectkey.md) | string | An identifier of the experiment subject, for example a user ID. | + diff --git a/docs/js-client-sdk.iclientconfig.subjectattributes.md b/docs/js-client-sdk.iclientconfig.subjectattributes.md new file mode 100644 index 0000000..f866e53 --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.subjectattributes.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [subjectAttributes](./js-client-sdk.iclientconfig.subjectattributes.md) + +## IClientConfig.subjectAttributes property + +Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. + +Signature: + +```typescript +subjectAttributes?: Record; +``` diff --git a/docs/js-client-sdk.iclientconfig.subjectkey.md b/docs/js-client-sdk.iclientconfig.subjectkey.md new file mode 100644 index 0000000..faccddd --- /dev/null +++ b/docs/js-client-sdk.iclientconfig.subjectkey.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [subjectKey](./js-client-sdk.iclientconfig.subjectkey.md) + +## IClientConfig.subjectKey property + +An identifier of the experiment subject, for example a user ID. + +Signature: + +```typescript +subjectKey: string; +``` diff --git a/docs/js-client-sdk.init.md b/docs/js-client-sdk.init.md new file mode 100644 index 0000000..61a8998 --- /dev/null +++ b/docs/js-client-sdk.init.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [init](./js-client-sdk.init.md) + +## init() function + +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. + +Signature: + +```typescript +export declare function init(config: IClientConfig): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| config | [IClientConfig](./js-client-sdk.iclientconfig.md) | client configuration | + +Returns: + +Promise<IEppoClient> + diff --git a/docs/js-client-sdk.md b/docs/js-client-sdk.md index 43892bb..601deef 100644 --- a/docs/js-client-sdk.md +++ b/docs/js-client-sdk.md @@ -8,5 +8,12 @@ | Function | Description | | --- | --- | -| [dummy()](./js-client-sdk.dummy.md) | Test documentation | +| [getInstance()](./js-client-sdk.getinstance.md) | Used to access a singleton SDK client instance. Use the method after calling init() to initialize the client. | +| [init(config)](./js-client-sdk.init.md) | Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. | + +## Interfaces + +| Interface | Description | +| --- | --- | +| [IClientConfig](./js-client-sdk.iclientconfig.md) | Configuration used for initializing the Eppo client | diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 4ec319a..22d37c4 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -4,8 +4,22 @@ ```ts +// Warning: (ae-forgotten-export) The symbol "IEppoClient" needs to be exported by the entry point index.d.ts +// // @public -export function dummy(): string; +export function getInstance(): IEppoClient; + +// @public +export interface IClientConfig { + apiKey: string; + baseUrl?: string; + // Warning: (ae-forgotten-export) The symbol "AttributeValueType" needs to be exported by the entry point index.d.ts + subjectAttributes?: Record; + subjectKey: string; +} + +// @public +export function init(config: IClientConfig): Promise; // (No @packageDocumentation comment for this package) diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..354518d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const REQUEST_TIMEOUT_MILLIS = 1000; +export const BASE_URL = 'https://eppo.cloud/api'; diff --git a/src/eppo-client.ts b/src/eppo-client.ts new file mode 100644 index 0000000..c0a3c5b --- /dev/null +++ b/src/eppo-client.ts @@ -0,0 +1,76 @@ +import { createHash } from 'crypto'; + +import { IExperimentConfiguration } from './experiment/experiment-configuration'; +import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor'; +import { Rule } from './experiment/rule'; +import { matchesAnyRule } from './rule_evaluator'; +import { getShard, isShardInRange } from './shard'; +import { validateNotBlank } from './validation'; + +/** + * Client for assigning experiment variations. + * @public + */ +export interface IEppoClient { + /** + * Maps a subject to a variation for a given experiment. + * + * @param experimentKey experiment identifier + * @returns a variation value if the subject is part of the experiment sample, otherwise null + * @public + */ + getAssignment(experimentKey: string): string; +} + +export default class EppoClient implements IEppoClient { + constructor( + private subjectKey: string, + private configurationRequestor: ExperimentConfigurationRequestor, + private subjectAttributes = {}, + ) {} + + getAssignment(experimentKey: string): string { + validateNotBlank(experimentKey, 'Invalid argument: experimentKey cannot be blank'); + const experimentConfig = this.configurationRequestor.getConfiguration(experimentKey); + if ( + !experimentConfig?.enabled || + !this.subjectAttributesSatisfyRules(experimentConfig.rules) || + !this.isInExperimentSample(experimentKey, experimentConfig) + ) { + return null; + } + const override = this.getSubjectVariationOverride(experimentConfig); + if (override) { + return override; + } + const { variations, subjectShards } = experimentConfig; + const shard = getShard(`assignment-${this.subjectKey}-${experimentKey}`, subjectShards); + return variations.find((variation) => isShardInRange(shard, variation.shardRange)).name; + } + + private subjectAttributesSatisfyRules(rules?: Rule[]) { + if (!rules || rules.length === 0) { + return true; + } + return matchesAnyRule(this.subjectAttributes || {}, rules); + } + + private getSubjectVariationOverride(experimentConfig: IExperimentConfiguration): string { + const subjectHash = createHash('md5').update(this.subjectKey).digest('hex'); + return experimentConfig.overrides[subjectHash]; + } + + /** + * This checks whether the subject is included in the experiment sample. + * It is used to determine whether the subject should be assigned to a variant. + * Given a hash function output (bucket), check whether the bucket is between 0 and exposure_percent * total_buckets. + */ + private isInExperimentSample( + experimentKey: string, + experimentConfig: IExperimentConfiguration, + ): boolean { + const { percentExposure, subjectShards } = experimentConfig; + const shard = getShard(`exposure-${this.subjectKey}-${experimentKey}`, subjectShards); + return shard <= percentExposure * subjectShards; + } +} diff --git a/src/experiment/experiment-configuration-requestor.ts b/src/experiment/experiment-configuration-requestor.ts new file mode 100644 index 0000000..1f406eb --- /dev/null +++ b/src/experiment/experiment-configuration-requestor.ts @@ -0,0 +1,24 @@ +import HttpClient from '../http-client'; +import { EppoSessionStorage } from '../storage'; + +import { IExperimentConfiguration } from './experiment-configuration'; + +const RAC_ENDPOINT = '/randomized_assignment/config'; + +interface IRandomizedAssignmentConfig { + experiments: Record; +} + +export default class ExperimentConfigurationRequestor { + constructor(private configurationStore: EppoSessionStorage, private httpClient: HttpClient) {} + + getConfiguration(experiment: string): IExperimentConfiguration { + return this.configurationStore.get(experiment); + } + + async fetchAndStoreConfigurations(): Promise> { + const responseData = await this.httpClient.get(RAC_ENDPOINT); + this.configurationStore.setEntries(responseData.experiments); + return responseData.experiments; + } +} diff --git a/src/experiment/experiment-configuration.ts b/src/experiment/experiment-configuration.ts new file mode 100644 index 0000000..1e57c5c --- /dev/null +++ b/src/experiment/experiment-configuration.ts @@ -0,0 +1,12 @@ +import { Rule } from './rule'; +import { IVariation } from './variation'; + +export interface IExperimentConfiguration { + name: string; + percentExposure: number; + enabled: boolean; + subjectShards: number; + variations: IVariation[]; + overrides: Record; + rules?: Rule[]; +} diff --git a/src/experiment/rule.ts b/src/experiment/rule.ts new file mode 100644 index 0000000..9d541ad --- /dev/null +++ b/src/experiment/rule.ts @@ -0,0 +1,19 @@ +export type AttributeValueType = string | number; + +export enum OperatorType { + MATCHES = 'MATCHES', + GTE = 'GTE', + GT = 'GT', + LTE = 'LTE', + LT = 'LT', +} + +export interface Condition { + operator: OperatorType; + attribute: string; + value: AttributeValueType; +} + +export interface Rule { + conditions: Condition[]; +} diff --git a/src/experiment/variation.ts b/src/experiment/variation.ts new file mode 100644 index 0000000..957be92 --- /dev/null +++ b/src/experiment/variation.ts @@ -0,0 +1,9 @@ +export interface IShardRange { + start: number; + end: number; +} + +export interface IVariation { + name: string; + shardRange: IShardRange; +} diff --git a/src/http-client.ts b/src/http-client.ts new file mode 100644 index 0000000..2da9641 --- /dev/null +++ b/src/http-client.ts @@ -0,0 +1,34 @@ +import { AxiosInstance } from 'axios'; + +interface ISdkParams { + apiKey: string; + sdkVersion: string; + sdkName: string; +} + +export class HttpRequestError extends Error { + constructor(public message: string, public status: number) { + super(message); + } +} + +export default class HttpClient { + constructor(private axiosInstance: AxiosInstance, private sdkParams: ISdkParams) {} + + async get(resource: string): Promise { + try { + const response = await this.axiosInstance.get(resource, { + params: this.sdkParams, + }); + return response.data; + } catch (error) { + this.handleHttpError(error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private handleHttpError(error: any) { + const status = error?.response?.status; + throw new HttpRequestError(error.message, status); + } +} diff --git a/src/index.ts b/src/index.ts index acb58d0..714ce52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,90 @@ +import axios from 'axios'; + +import { BASE_URL, REQUEST_TIMEOUT_MILLIS } from './constants'; +import EppoClient, { IEppoClient } from './eppo-client'; +import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor'; +import HttpClient from './http-client'; +import { AttributeValueType } from './rule'; +import { sdkName, sdkVersion } from './sdk-data'; +import { EppoSessionStorage } from './storage'; +import { validateNotBlank } from './validation'; + /** - * Test documentation + * Configuration used for initializing the Eppo client * @public */ -export function dummy(): string { - return 'Hello world'; +export interface IClientConfig { + /** + * Eppo API key + */ + apiKey: string; + + /** + * An identifier of the experiment subject, for example a user ID. + */ + subjectKey: string; + + /** + * Optional attributes associated with the subject, for example name and email. + * The subject attributes are used for evaluating any targeting rules tied to the experiment. + */ + subjectAttributes?: Record; + + /** + * Base URL of the Eppo API. + * Clients should use the default setting in most cases. + */ + baseUrl?: string; +} + +export { IEppoClient } from './eppo-client'; +export { AttributeValueType } from './rule'; + +let clientInstance: IEppoClient = null; + +/** + * Initializes the Eppo client with configuration parameters. + * This method should be called once on application startup. + * After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. + * @param config client configuration + * @public + */ +export async function init(config: IClientConfig): Promise { + validateNotBlank(config.apiKey, 'API key required'); + validateNotBlank(config.subjectKey, 'subjectKey is required'); + const configurationStore = new EppoSessionStorage(); + const axiosInstance = axios.create({ + baseURL: config.baseUrl || BASE_URL, + timeout: REQUEST_TIMEOUT_MILLIS, + }); + const httpClient = new HttpClient(axiosInstance, { + apiKey: config.apiKey, + sdkName, + sdkVersion, + }); + const configurationRequestor = new ExperimentConfigurationRequestor( + configurationStore, + httpClient, + ); + clientInstance = new EppoClient( + config.subjectKey, + configurationRequestor, + config.subjectAttributes, + ); + if (!configurationStore.isSessionStorageInitialized()) { + await configurationRequestor.fetchAndStoreConfigurations(); + } + return clientInstance; +} + +/** + * Used to access a singleton SDK client instance. + * Use the method after calling init() to initialize the client. + * @returns a singleton client instance + */ +export function getInstance(): IEppoClient { + if (!clientInstance) { + throw Error('Expected init() to be called to initialize a client instance'); + } + return clientInstance; } diff --git a/src/rule.ts b/src/rule.ts new file mode 100644 index 0000000..9d541ad --- /dev/null +++ b/src/rule.ts @@ -0,0 +1,19 @@ +export type AttributeValueType = string | number; + +export enum OperatorType { + MATCHES = 'MATCHES', + GTE = 'GTE', + GT = 'GT', + LTE = 'LTE', + LT = 'LT', +} + +export interface Condition { + operator: OperatorType; + attribute: string; + value: AttributeValueType; +} + +export interface Rule { + conditions: Condition[]; +} diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts new file mode 100644 index 0000000..ead3942 --- /dev/null +++ b/src/rule_evaluator.ts @@ -0,0 +1,59 @@ +import { Condition, OperatorType, Rule, AttributeValueType } from './rule'; + +export function matchesAnyRule( + subjectAttributes: Record, + rules: Rule[], +): boolean { + for (const rule of rules) { + if (matchesRule(subjectAttributes, rule)) { + return true; + } + } + return false; +} + +function matchesRule(subjectAttributes: Record, rule: Rule): boolean { + const conditionEvaluations = evaluateRuleConditions(subjectAttributes, rule.conditions); + return !conditionEvaluations.includes(false); +} + +function evaluateRuleConditions( + subjectAttributes: Record, + conditions: Condition[], +): boolean[] { + return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)); +} + +function evaluateCondition( + subjectAttributes: Record, + condition: Condition, +): boolean { + const value = subjectAttributes[condition.attribute]; + if (value) { + switch (condition.operator) { + case OperatorType.GTE: + return compareNumber(value, condition.value, (a, b) => a >= b); + case OperatorType.GT: + return compareNumber(value, condition.value, (a, b) => a > b); + case OperatorType.LTE: + return compareNumber(value, condition.value, (a, b) => a <= b); + case OperatorType.LT: + return compareNumber(value, condition.value, (a, b) => a < b); + case OperatorType.MATCHES: + return new RegExp(condition.value as string).test(value as string); + } + } + return false; +} + +function compareNumber( + attributeValue: AttributeValueType, + conditionValue: AttributeValueType, + compareFn: (a: number, b: number) => boolean, +) { + return ( + typeof attributeValue === 'number' && + typeof conditionValue === 'number' && + compareFn(attributeValue, conditionValue) + ); +} diff --git a/src/sdk-data.ts b/src/sdk-data.ts new file mode 100644 index 0000000..46d7b8f --- /dev/null +++ b/src/sdk-data.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageJson = require('../package.json'); + +export const sdkVersion = packageJson.version; +export const sdkName = 'js-client-sdk'; diff --git a/src/shard.ts b/src/shard.ts new file mode 100644 index 0000000..a4dc3b1 --- /dev/null +++ b/src/shard.ts @@ -0,0 +1,15 @@ +import { createHash } from 'crypto'; + +import { IShardRange } from './experiment/variation'; + +export function getShard(input: string, subjectShards: number): number { + const hashOutput = createHash('md5').update(input).digest('hex'); + // get the first 4 bytes of the md5 hex string and parse it using base 16 + // (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer) + const intFromHash = parseInt(hashOutput.slice(0, 8), 16); + return intFromHash % subjectShards; +} + +export function isShardInRange(shard: number, range: IShardRange) { + return shard >= range.start && shard < range.end; +} diff --git a/src/storage.spec.ts b/src/storage.spec.ts new file mode 100644 index 0000000..e363c3e --- /dev/null +++ b/src/storage.spec.ts @@ -0,0 +1,37 @@ +/** + * @jest-environment jsdom + */ + +import { EppoSessionStorage } from './storage'; + +describe('EppoSessionStorage', () => { + interface ITestEntry { + items: string[]; + } + const config1 = { + items: ['test', 'control', 'blue'], + }; + const config2 = { + items: ['red'], + }; + + const storage = new EppoSessionStorage(); + + beforeEach(() => { + window.sessionStorage.clear(); + }); + + describe('get', () => { + it('returns null if entry is not present', () => { + expect(storage.get('does not exist')).toEqual(null); + }); + + it('returns stored entries', () => { + expect(storage.isSessionStorageInitialized()).toEqual(false); + storage.setEntries({ key1: config1, key2: config2 }); + expect(storage.isSessionStorageInitialized()).toEqual(true); + expect(storage.get('key1')).toEqual(config1); + expect(storage.get('key2')).toEqual(config2); + }); + }); +}); diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..54eba9f --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,53 @@ +const SESSION_STORAGE_INITIALIZED = 'eppo-session-storage-initialized'; + +export class EppoSessionStorage { + // Fallback storage in case the user has disabled session storage in the browser. + private fallbackStorage: Record = {}; + + public get(key: string): T { + let serializedEntry; + if (this.hasWindowSessionStorage()) { + serializedEntry = window.sessionStorage.getItem(key); + } else { + serializedEntry = this.fallbackStorage[key]; + } + if (serializedEntry) { + return JSON.parse(serializedEntry); + } + return null; + } + + private hasWindowSessionStorage(): boolean { + try { + return typeof window !== 'undefined' && !!window.sessionStorage; + } catch { + // Some browsers throw an error if session storage is disabled and you try to access it + return false; + } + } + + public isSessionStorageInitialized(): boolean { + return !!this.get(SESSION_STORAGE_INITIALIZED); + } + + public setEntries(entries: Record) { + if (this.hasWindowSessionStorage()) { + this.setEntriesInSessionStorage(entries); + } else { + this.setEntriesInFallbackStorage(entries); + } + } + + private setEntriesInSessionStorage(entries: Record) { + Object.entries(entries).forEach(([key, val]) => { + window.sessionStorage.setItem(key, JSON.stringify(val)); + }); + window.sessionStorage.setItem(SESSION_STORAGE_INITIALIZED, 'true'); + } + + private setEntriesInFallbackStorage(entries: Record) { + Object.entries(entries).forEach(([key, val]) => { + this.fallbackStorage[key] = JSON.stringify(val); + }); + } +} diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..94177cf --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,7 @@ +export class InvalidArgumentError extends Error {} + +export function validateNotBlank(value: string, errorMessage: string) { + if (value == null || value.length === 0) { + throw new InvalidArgumentError(errorMessage); + } +} From 5be86182d9525a7c8ef5223a211d835a8b777604 Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Fri, 17 Jun 2022 10:16:11 -0700 Subject: [PATCH 2/6] simplify session storage --- src/index.ts | 2 +- src/storage.spec.ts | 4 ++-- src/storage.ts | 39 +++++++++++---------------------------- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index 714ce52..4d4b3c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,7 +71,7 @@ export async function init(config: IClientConfig): Promise { configurationRequestor, config.subjectAttributes, ); - if (!configurationStore.isSessionStorageInitialized()) { + if (!configurationStore.isInitialized()) { await configurationRequestor.fetchAndStoreConfigurations(); } return clientInstance; diff --git a/src/storage.spec.ts b/src/storage.spec.ts index e363c3e..57df0ed 100644 --- a/src/storage.spec.ts +++ b/src/storage.spec.ts @@ -27,9 +27,9 @@ describe('EppoSessionStorage', () => { }); it('returns stored entries', () => { - expect(storage.isSessionStorageInitialized()).toEqual(false); + expect(storage.isInitialized()).toEqual(false); storage.setEntries({ key1: config1, key2: config2 }); - expect(storage.isSessionStorageInitialized()).toEqual(true); + expect(storage.isInitialized()).toEqual(true); expect(storage.get('key1')).toEqual(config1); expect(storage.get('key2')).toEqual(config2); }); diff --git a/src/storage.ts b/src/storage.ts index 54eba9f..7f53c66 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,53 +1,36 @@ const SESSION_STORAGE_INITIALIZED = 'eppo-session-storage-initialized'; export class EppoSessionStorage { - // Fallback storage in case the user has disabled session storage in the browser. - private fallbackStorage: Record = {}; - public get(key: string): T { - let serializedEntry; if (this.hasWindowSessionStorage()) { - serializedEntry = window.sessionStorage.getItem(key); - } else { - serializedEntry = this.fallbackStorage[key]; - } - if (serializedEntry) { - return JSON.parse(serializedEntry); + const serializedEntry = window.sessionStorage.getItem(key); + if (serializedEntry) { + return JSON.parse(serializedEntry); + } } return null; } + // Checks whether session storage is enabled in the browser (the user might have disabled it). private hasWindowSessionStorage(): boolean { try { return typeof window !== 'undefined' && !!window.sessionStorage; } catch { - // Some browsers throw an error if session storage is disabled and you try to access it + // Chrome throws an error if session storage is disabled and you try to access it return false; } } - public isSessionStorageInitialized(): boolean { + public isInitialized(): boolean { return !!this.get(SESSION_STORAGE_INITIALIZED); } public setEntries(entries: Record) { if (this.hasWindowSessionStorage()) { - this.setEntriesInSessionStorage(entries); - } else { - this.setEntriesInFallbackStorage(entries); + Object.entries(entries).forEach(([key, val]) => { + window.sessionStorage.setItem(key, JSON.stringify(val)); + }); + window.sessionStorage.setItem(SESSION_STORAGE_INITIALIZED, 'true'); } } - - private setEntriesInSessionStorage(entries: Record) { - Object.entries(entries).forEach(([key, val]) => { - window.sessionStorage.setItem(key, JSON.stringify(val)); - }); - window.sessionStorage.setItem(SESSION_STORAGE_INITIALIZED, 'true'); - } - - private setEntriesInFallbackStorage(entries: Record) { - Object.entries(entries).forEach(([key, val]) => { - this.fallbackStorage[key] = JSON.stringify(val); - }); - } } From 77431290fd33842a7a84d878fd5e4f8620dc3475 Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Fri, 17 Jun 2022 14:15:08 -0700 Subject: [PATCH 3/6] integration tests --- .gitignore | 2 + jest.config.js | 1 + package.json | 5 +- src/eppo-client.spec.ts | 188 +++++++++++++++++ test/globalSetup.ts | 25 +++ test/testHelpers.ts | 24 +++ yarn.lock | 452 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 691 insertions(+), 6 deletions(-) create mode 100644 src/eppo-client.spec.ts create mode 100644 test/globalSetup.ts create mode 100644 test/testHelpers.ts diff --git a/.gitignore b/.gitignore index bffef8d..dd7f45f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ logs/ temp .env yarn-error.log + +test/assignmentTestData diff --git a/jest.config.js b/jest.config.js index ad72a08..e73b408 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ module.exports = { transform: { '^.+\\.(t|j)s$': 'ts-jest', }, + globalSetup: './test/globalSetup.ts', collectCoverageFrom: ['**/*.(t|j)s'], coverageDirectory: 'coverage/', testEnvironment: 'node', diff --git a/package.json b/package.json index e5e01de..0bd0998 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/Eppo-exp/js-client-sdk#readme", "devDependencies": { + "@google-cloud/storage": "^6.1.0", "@microsoft/api-documenter": "^7.17.17", "@microsoft/api-extractor": "^7.25.0", "@types/jest": "^28.1.1", @@ -44,8 +45,10 @@ "jest": "^28.1.1", "jest-environment-jsdom": "^28.1.1", "prettier": "^2.7.1", + "testdouble": "^3.16.6", "ts-jest": "^28.0.5", - "typescript": "^4.7.3" + "typescript": "^4.7.3", + "xhr-mock": "^2.5.1" }, "dependencies": { "axios": "^0.27.2" diff --git a/src/eppo-client.spec.ts b/src/eppo-client.spec.ts new file mode 100644 index 0000000..f1ebf07 --- /dev/null +++ b/src/eppo-client.spec.ts @@ -0,0 +1,188 @@ +/** + * @jest-environment jsdom + */ +import * as td from 'testdouble'; +import mock from 'xhr-mock'; + +import { IAssignmentTestCase, readAssignmentTestData } from '../test/testHelpers'; + +import EppoClient from './eppo-client'; +import { IExperimentConfiguration } from './experiment/experiment-configuration'; +import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor'; +import { IVariation } from './experiment/variation'; +import { OperatorType } from './rule'; + +import { init } from '.'; + +describe('EppoClient E2E test', () => { + beforeAll(() => { + window.sessionStorage.clear(); + mock.setup(); + mock.get(/randomized_assignment\/config*/, (_req, res) => { + const testCases: IAssignmentTestCase[] = readAssignmentTestData(); + const assignmentConfig: Record = {}; + testCases.forEach(({ experiment, percentExposure, variations }) => { + assignmentConfig[experiment] = { + name: experiment, + percentExposure, + enabled: true, + subjectShards: 10000, + variations, + overrides: {}, + rules: [], + }; + }); + return res.status(200).body(JSON.stringify({ experiments: assignmentConfig })); + }); + }); + + afterAll(() => { + mock.teardown(); + }); + + describe('getAssignment', () => { + it.each(readAssignmentTestData())( + 'test variation assignment splits', + async ({ + variations, + experiment, + percentExposure, + subjects, + expectedAssignments, + }: IAssignmentTestCase) => { + console.log(`---- Test Case for ${experiment} Experiment ----`); + const assignments = await getAssignments(subjects, experiment); + // verify the assingments don't change across test runs (deterministic) + expect(assignments).toEqual(expectedAssignments); + const expectedVariationSplitPercentage = percentExposure / variations.length; + const unassignedCount = assignments.filter((assignment) => assignment == null).length; + expectToBeCloseToPercentage(unassignedCount / assignments.length, 1 - percentExposure); + variations.forEach((variation) => { + validateAssignmentCounts(assignments, expectedVariationSplitPercentage, variation); + }); + }, + ); + }); + + it('returns subject from overrides', () => { + const mockConfigRequestor = td.object(); + const experiment = 'experiment_5'; + td.when(mockConfigRequestor.getConfiguration(experiment)).thenReturn({ + name: experiment, + percentExposure: 1, + enabled: true, + subjectShards: 100, + variations: [ + { + name: 'control', + shardRange: { + start: 0, + end: 33, + }, + }, + { + name: 'variant-1', + shardRange: { + start: 34, + end: 66, + }, + }, + { + name: 'variant-2', + shardRange: { + start: 67, + end: 100, + }, + }, + ], + overrides: { + a90ea45116d251a43da56e03d3dd7275: 'variant-2', + }, + }); + const client = new EppoClient('subject-1', mockConfigRequestor); + const assignment = client.getAssignment(experiment); + expect(assignment).toEqual('variant-2'); + }); + + it('only returns variation if subject matches rules', () => { + const mockConfigRequestor = td.object(); + const experiment = 'experiment_5'; + td.when(mockConfigRequestor.getConfiguration(experiment)).thenReturn({ + name: experiment, + percentExposure: 1, + enabled: true, + subjectShards: 100, + variations: [ + { + name: 'control', + shardRange: { + start: 0, + end: 50, + }, + }, + { + name: 'treatment', + shardRange: { + start: 50, + end: 100, + }, + }, + ], + overrides: {}, + rules: [ + { + conditions: [ + { + operator: OperatorType.GT, + attribute: 'appVersion', + value: 10, + }, + ], + }, + ], + }); + let client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 9 }); + let assignment = client.getAssignment(experiment); + expect(assignment).toEqual(null); + client = new EppoClient('subject-1', mockConfigRequestor); + assignment = client.getAssignment(experiment); + expect(assignment).toEqual(null); + client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 11 }); + assignment = client.getAssignment(experiment); + expect(assignment).toEqual('control'); + }); + + function validateAssignmentCounts( + assignments: string[], + expectedPercentage: number, + variation: IVariation, + ) { + const assignedCount = assignments.filter((assignment) => assignment === variation.name).length; + console.log( + `Expect variation ${variation.name} percentage of ${ + assignedCount / assignments.length + } to be close to ${expectedPercentage}`, + ); + expectToBeCloseToPercentage(assignedCount / assignments.length, expectedPercentage); + } + + // expect assignment count to be within 5 percentage points of the expected count (because the hash output is random) + function expectToBeCloseToPercentage(percentage: number, expectedPercentage: number) { + expect(percentage).toBeGreaterThanOrEqual(expectedPercentage - 0.05); + expect(percentage).toBeLessThanOrEqual(expectedPercentage + 0.05); + } + + async function getAssignments(subjects: string[], experiment: string): Promise { + const assignments: string[] = []; + for (const subjectKey of subjects) { + const client = await init({ + apiKey: 'dummy', + baseUrl: 'http://127.0.0.1:4000', + subjectKey, + }); + const assignment = client.getAssignment(experiment); + assignments.push(assignment); + } + return assignments; + } +}); diff --git a/test/globalSetup.ts b/test/globalSetup.ts new file mode 100644 index 0000000..fb3755e --- /dev/null +++ b/test/globalSetup.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs'; + +import { Storage } from '@google-cloud/storage'; + +import { TEST_DATA_DIR } from './testHelpers'; + +const storage = new Storage(); + +async function downloadTestDataFiles() { + const [files] = await storage.bucket('sdk-test-data').getFiles({ + prefix: 'assignment/test-case', + }); + return Promise.all( + files.map((file, index) => { + return file.download({ destination: `${TEST_DATA_DIR}test-case-${index}.json` }); + }), + ); +} + +export default async () => { + if (!fs.existsSync(TEST_DATA_DIR)) { + fs.mkdirSync(TEST_DATA_DIR); + await downloadTestDataFiles(); + } +}; diff --git a/test/testHelpers.ts b/test/testHelpers.ts new file mode 100644 index 0000000..d6b31a8 --- /dev/null +++ b/test/testHelpers.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs'; + +import { IVariation } from '../src/experiment/variation'; + +export const TEST_DATA_DIR = './test/assignmentTestData/'; + +export interface IAssignmentTestCase { + experiment: string; + percentExposure: number; + variations: IVariation[]; + subjects: string[]; + expectedAssignments: string[]; +} + +export function readAssignmentTestData(): IAssignmentTestCase[] { + const testDataDir = './test/assignmentTestData/'; + const testCaseData: IAssignmentTestCase[] = []; + const testCaseFiles = fs.readdirSync(testDataDir); + testCaseFiles.forEach((file) => { + const testCase = JSON.parse(fs.readFileSync(testDataDir + file, 'utf8')); + testCaseData.push(testCase); + }); + return testCaseData; +} diff --git a/yarn.lock b/yarn.lock index 208aeb9..1d4ddd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -299,6 +299,50 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@google-cloud/paginator@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" + integrity sha512-jJNutk0arIQhmpUUQJPJErsojqo834KcyB6X7a1mxuic8i1tKXxde8E69IZxNZawRIlZdIK2QY4WALvlK5MzYQ== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-2.1.1.tgz#ae6af4fee02d78d044ae434699a630f8df0084ef" + integrity sha512-+rssMZHnlh0twl122gXY4/aCrk0G1acBqkHFfYddtsqpYXGxA29nj9V5V9SfC+GyOG00l650f6lG9KL+EpFEWQ== + +"@google-cloud/promisify@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-3.0.0.tgz#5cd6941fc30c4acac18051706aa5af96069bd3e3" + integrity sha512-91ArYvRgXWb73YvEOBMmOcJc0bDRs5yiVHnqkwoG0f3nm7nZuipllz6e7BvFESBvjkDTBC0zMD8QxedUwNLc1A== + +"@google-cloud/storage@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-6.1.0.tgz#f882a969c5637ff445764d7b172f68fd0d9e63f8" + integrity sha512-zqZwzpRWCJuPne7x9Vc2H79zANl0uh9bNPGis0xAuC88ZEvBXfQqYCAVyiL1YIxi7rf51l8wy9vBr1pONMfxxA== + dependencies: + "@google-cloud/paginator" "^3.0.7" + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^3.0.0" + abort-controller "^3.0.0" + arrify "^2.0.0" + async-retry "^1.3.3" + compressible "^2.0.12" + duplexify "^4.0.0" + ent "^2.2.0" + extend "^3.0.2" + gaxios "^5.0.0" + google-auth-library "^8.0.1" + mime "^3.0.0" + mime-types "^2.0.8" + p-limit "^3.0.1" + pumpify "^2.0.0" + retry-request "^5.0.0" + stream-events "^1.0.4" + teeny-request "^8.0.0" + uuid "^8.0.0" + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -913,6 +957,13 @@ abab@^2.0.5, abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-globals@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" @@ -1035,6 +1086,18 @@ array.prototype.flat@^1.2.5: es-abstract "^1.19.2" es-shim-unscopables "^1.0.0" +arrify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + +async-retry@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" + integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== + dependencies: + retry "0.13.1" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1113,6 +1176,16 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bignumber.js@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673" + integrity sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1158,6 +1231,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -1283,6 +1361,13 @@ commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +compressible@^2.0.12: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1420,6 +1505,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -1427,6 +1517,23 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +duplexify@^4.0.0, duplexify@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0" + integrity sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw== + dependencies: + end-of-stream "^1.4.1" + inherits "^2.0.3" + readable-stream "^3.1.1" + stream-shift "^1.0.0" + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + electron-to-chromium@^1.4.147: version "1.4.156" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.156.tgz#fc398e1bfbe586135351ebfaf198473a82923af5" @@ -1442,6 +1549,18 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +ent@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" + integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -1706,6 +1825,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -1737,6 +1861,11 @@ expect@^28.1.1: jest-message-util "^28.1.1" jest-util "^28.1.1" +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -1768,6 +1897,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-text-encoding@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" + integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -1882,6 +2016,36 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +gaxios@^4.0.0: + version "4.3.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.3.3.tgz#d44bdefe52d34b6435cc41214fdb160b64abfc22" + integrity sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.7" + +gaxios@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-5.0.0.tgz#df11e5d0a45831dd39eb5fbbba0d6a6b09815e70" + integrity sha512-VD/yc5ln6XU8Ch1hyYY6kRMBE0Yc2np3fPyeJeYHhrPs1i8rgnsApPMWyrugkl7LLoSqpOJVBWlQIa87OAvt8Q== + dependencies: + abort-controller "^3.0.0" + extend "^3.0.2" + https-proxy-agent "^5.0.0" + is-stream "^2.0.0" + node-fetch "^2.6.7" + +gcp-metadata@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-5.0.0.tgz#a00f999f60a4461401e7c515f8a3267cfb401ee7" + integrity sha512-gfwuX3yA3nNsHSWUL4KG90UulNiq922Ukj3wLTrcnX33BB7PwB1o0ubR8KVvXu9nJH+P5w1j2SQSNNqto+H0DA== + dependencies: + gaxios "^5.0.0" + json-bigint "^1.0.0" + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -1945,6 +2109,14 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" +global@^4.3.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -1969,11 +2141,42 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +google-auth-library@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.0.2.tgz#5fa0f2d3795c3e4019d2bb315ade4454cc9c30b5" + integrity sha512-HoG+nWFAThLovKpvcbYzxgn+nBJPTfAwtq0GxPN821nOO+21+8oP7MoEHfd1sbDulUFFGfcjJr2CnJ4YssHcyg== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^5.0.0" + gcp-metadata "^5.0.0" + gtoken "^5.3.2" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-p12-pem@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" + integrity sha512-HHuHmkLgwjdmVRngf5+gSmpkyaRI6QmOg77J8tkNBHhNEI62sGHyw4/+UkgyZEI7h84NbWprXDJ+sa3xOYFvTg== + dependencies: + node-forge "^1.3.1" + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.9: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +gtoken@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.3.2.tgz#deb7dc876abe002178e0515e383382ea9446d58f" + integrity sha512-gkvEKREW7dXWF8NV8pVrKfW7WqReAmjjkMBh6lNCCGOM4ucS0r0YyXXl0r/9Yj8wcW/32ISkfc8h5mPTDbtifQ== + dependencies: + gaxios "^4.0.0" + google-p12-pem "^3.1.3" + jws "^4.0.0" + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -2100,7 +2303,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2: +inherits@2, inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2192,6 +2395,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -2205,6 +2413,11 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA== + is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" @@ -2753,6 +2966,13 @@ jsesc@^2.5.1: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" @@ -2787,6 +3007,23 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -2853,7 +3090,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@~4.17.15: +lodash@^4.17.15, lodash@^4.17.21, lodash@~4.17.15: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -2902,23 +3139,35 @@ micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0: +mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: +mime-types@^2.0.8, mime-types@^2.1.12: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ== + dependencies: + dom-walk "^0.1.0" + minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -2951,6 +3200,18 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@^2.6.1, node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -3007,7 +3268,7 @@ object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" -once@^1.3.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -3059,6 +3320,13 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -3198,6 +3466,11 @@ pretty-format@^28.1.1: ansi-styles "^5.0.0" react-is "^18.0.0" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -3211,16 +3484,51 @@ psl@^1.1.33: resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-2.0.1.tgz#abfc7b5a621307c728b551decbbefb51f0e4aa1e" + integrity sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw== + dependencies: + duplexify "^4.1.1" + inherits "^2.0.3" + pump "^3.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw== + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quibble@^0.6.7: + version "0.6.9" + resolved "https://registry.yarnpkg.com/quibble/-/quibble-0.6.9.tgz#1e7b24a72b59bdb6cc272318448cd6b7eb61fbe8" + integrity sha512-EotkZs/lqgDdGsKzdmZuqu2ATgupQzhByUZ8oL3ElzCKDhXmgVLrX+WDe/StvrfB80h4EPOTElXuQifcfJwwFw== + dependencies: + lodash "^4.17.21" + resolve "^1.20.0" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -3231,6 +3539,15 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +readable-stream@^3.1.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" @@ -3296,6 +3613,19 @@ resolve@~1.19.0: is-core-module "^2.1.0" path-parse "^1.0.6" +retry-request@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-5.0.1.tgz#c6be2a4a36f1554ba3251fa8fd945af26ee0e9ec" + integrity sha512-lxFKrlBt0OZzCWh/V0uPEN0vlr3OhdeXnpeY5OES+ckslm791Cb1D5P7lJUSnY7J5hiCjcyaUGmzCnIGDCUBig== + dependencies: + debug "^4.1.1" + extend "^3.0.2" + +retry@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -3315,6 +3645,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-buffer@^5.0.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -3405,6 +3740,18 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +stream-events@^1.0.4, stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + +stream-shift@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" + integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== + string-argv@~0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" @@ -3445,6 +3792,21 @@ string.prototype.trimstart@^1.0.5: define-properties "^1.1.4" es-abstract "^1.19.5" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +stringify-object-es5@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/stringify-object-es5/-/stringify-object-es5-2.5.0.tgz#057c3c9a90a127339bb9d1704a290bb7bd0a1ec5" + integrity sha512-vE7Xdx9ylG4JI16zy7/ObKUB+MtxuMcWlj/WHHr3+yAlQoN6sst2stU9E+2Qs3OrlJw/Pf3loWxL1GauEHf6MA== + dependencies: + is-plain-obj "^1.0.0" + is-regexp "^1.0.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -3472,6 +3834,11 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1, strip-json-comments@~3.1 resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -3511,6 +3878,17 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +teeny-request@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-8.0.0.tgz#9614410ba70114fd28ba7bf5077dce3e2f02adf7" + integrity sha512-6KEYxXI4lQPSDkXzXpPmJPNmo7oqduFFbhOEHf8sfsLbXyCsb+umUjBtMGAKhaSToD8JNCtQutTRefu29K64JA== + dependencies: + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + node-fetch "^2.6.1" + stream-events "^1.0.5" + uuid "^8.0.0" + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" @@ -3528,11 +3906,26 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +testdouble@^3.16.6: + version "3.16.6" + resolved "https://registry.yarnpkg.com/testdouble/-/testdouble-3.16.6.tgz#3e3fce4a5c8681378b6c7a6d18884746a9dfb1e8" + integrity sha512-mijMgc9y7buK9IG9zSVhzlXsFMqWbLQHRei4SLX7F7K4Qtrcnglg6lIMTCmNs6RwDUyLGWtpIe+TzkugYHB+qA== + dependencies: + lodash "^4.17.15" + quibble "^0.6.7" + stringify-object-es5 "^2.5.0" + theredoc "^1.0.0" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +theredoc@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/theredoc/-/theredoc-1.0.0.tgz#bcace376af6feb1873efbdd0f91ed026570ff062" + integrity sha512-KU3SA3TjRRM932jpNfD3u4Ec3bSvedyo5ITPI7zgWYnKep7BwQQaxlhI9qbO+lKJoRnoAbEVfMcAHRuKVYikDA== + throat@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" @@ -3576,6 +3969,11 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + ts-jest@^28.0.5: version "28.0.5" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-28.0.5.tgz#31776f768fba6dfc8c061d488840ed0c8eeac8b9" @@ -3673,6 +4071,24 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ== + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +uuid@^8.0.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -3713,6 +4129,11 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -3746,6 +4167,14 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -3796,6 +4225,14 @@ ws@^8.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.0.tgz#8e71c75e2f6348dbf8d78005107297056cb77769" integrity sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ== +xhr-mock@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/xhr-mock/-/xhr-mock-2.5.1.tgz#c591498a8269cc1ce5fefac20d590357affd348b" + integrity sha512-UKOjItqjFgPUwQGPmRAzNBn8eTfIhcGjBVGvKYAWxUQPQsXNGD6KEckGTiHwyaAUp9C9igQlnN1Mp79KWCg7CQ== + dependencies: + global "^4.3.0" + url "^0.11.0" + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" @@ -3834,6 +4271,11 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.0.0" +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + z-schema@~5.0.2: version "5.0.3" resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-5.0.3.tgz#68fafb9b735fc7f3c89eabb3e5a6353b4d7b4935" From 97ca89ad411688aef4a4ef78cf7e3c1fb5cc46f1 Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Fri, 17 Jun 2022 14:16:52 -0700 Subject: [PATCH 4/6] docs --- ...md => js-client-sdk.attributevaluetype.md} | 12 +++------ docs/js-client-sdk.getinstance.md | 2 +- docs/js-client-sdk.iclientconfig.md | 2 +- ...js-client-sdk.ieppoclient.getassignment.md | 26 +++++++++++++++++++ docs/js-client-sdk.ieppoclient.md | 20 ++++++++++++++ docs/js-client-sdk.init.md | 2 +- docs/js-client-sdk.md | 7 +++++ js-client-sdk.api.md | 11 +++++--- 8 files changed, 67 insertions(+), 15 deletions(-) rename docs/{js-client-sdk.dummy.md => js-client-sdk.attributevaluetype.md} (53%) create mode 100644 docs/js-client-sdk.ieppoclient.getassignment.md create mode 100644 docs/js-client-sdk.ieppoclient.md diff --git a/docs/js-client-sdk.dummy.md b/docs/js-client-sdk.attributevaluetype.md similarity index 53% rename from docs/js-client-sdk.dummy.md rename to docs/js-client-sdk.attributevaluetype.md index 768e9dd..f3f6d2f 100644 --- a/docs/js-client-sdk.dummy.md +++ b/docs/js-client-sdk.attributevaluetype.md @@ -1,17 +1,11 @@ -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [dummy](./js-client-sdk.dummy.md) +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [AttributeValueType](./js-client-sdk.attributevaluetype.md) -## dummy() function - -Test documentation +## AttributeValueType type Signature: ```typescript -export declare function dummy(): string; +export declare type AttributeValueType = string | number; ``` -Returns: - -string - diff --git a/docs/js-client-sdk.getinstance.md b/docs/js-client-sdk.getinstance.md index 08d0f73..53ce021 100644 --- a/docs/js-client-sdk.getinstance.md +++ b/docs/js-client-sdk.getinstance.md @@ -13,7 +13,7 @@ export declare function getInstance(): IEppoClient; ``` Returns: -IEppoClient +[IEppoClient](./js-client-sdk.ieppoclient.md) a singleton client instance diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index 7816df9..e64a9f1 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -18,6 +18,6 @@ export interface IClientConfig | --- | --- | --- | | [apiKey](./js-client-sdk.iclientconfig.apikey.md) | string | Eppo API key | | [baseUrl?](./js-client-sdk.iclientconfig.baseurl.md) | string | (Optional) Base URL of the Eppo API. Clients should use the default setting in most cases. | -| [subjectAttributes?](./js-client-sdk.iclientconfig.subjectattributes.md) | Record<string, AttributeValueType> | (Optional) Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. | +| [subjectAttributes?](./js-client-sdk.iclientconfig.subjectattributes.md) | Record<string, [AttributeValueType](./js-client-sdk.attributevaluetype.md)> | (Optional) Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. | | [subjectKey](./js-client-sdk.iclientconfig.subjectkey.md) | string | An identifier of the experiment subject, for example a user ID. | diff --git a/docs/js-client-sdk.ieppoclient.getassignment.md b/docs/js-client-sdk.ieppoclient.getassignment.md new file mode 100644 index 0000000..52c6748 --- /dev/null +++ b/docs/js-client-sdk.ieppoclient.getassignment.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IEppoClient](./js-client-sdk.ieppoclient.md) > [getAssignment](./js-client-sdk.ieppoclient.getassignment.md) + +## IEppoClient.getAssignment() method + +Maps a subject to a variation for a given experiment. + +Signature: + +```typescript +getAssignment(experimentKey: string): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| experimentKey | string | experiment identifier | + +Returns: + +string + +a variation value if the subject is part of the experiment sample, otherwise null + diff --git a/docs/js-client-sdk.ieppoclient.md b/docs/js-client-sdk.ieppoclient.md new file mode 100644 index 0000000..6435dc3 --- /dev/null +++ b/docs/js-client-sdk.ieppoclient.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IEppoClient](./js-client-sdk.ieppoclient.md) + +## IEppoClient interface + +Client for assigning experiment variations. + +Signature: + +```typescript +export interface IEppoClient +``` + +## Methods + +| Method | Description | +| --- | --- | +| [getAssignment(experimentKey)](./js-client-sdk.ieppoclient.getassignment.md) | Maps a subject to a variation for a given experiment. | + diff --git a/docs/js-client-sdk.init.md b/docs/js-client-sdk.init.md index 61a8998..ad19c65 100644 --- a/docs/js-client-sdk.init.md +++ b/docs/js-client-sdk.init.md @@ -20,5 +20,5 @@ export declare function init(config: IClientConfig): Promise; Returns: -Promise<IEppoClient> +Promise<[IEppoClient](./js-client-sdk.ieppoclient.md)> diff --git a/docs/js-client-sdk.md b/docs/js-client-sdk.md index 601deef..35175bf 100644 --- a/docs/js-client-sdk.md +++ b/docs/js-client-sdk.md @@ -16,4 +16,11 @@ | Interface | Description | | --- | --- | | [IClientConfig](./js-client-sdk.iclientconfig.md) | Configuration used for initializing the Eppo client | +| [IEppoClient](./js-client-sdk.ieppoclient.md) | Client for assigning experiment variations. | + +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [AttributeValueType](./js-client-sdk.attributevaluetype.md) | | diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index 22d37c4..da91546 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -4,8 +4,9 @@ ```ts -// Warning: (ae-forgotten-export) The symbol "IEppoClient" needs to be exported by the entry point index.d.ts -// +// @public (undocumented) +export type AttributeValueType = string | number; + // @public export function getInstance(): IEppoClient; @@ -13,11 +14,15 @@ export function getInstance(): IEppoClient; export interface IClientConfig { apiKey: string; baseUrl?: string; - // Warning: (ae-forgotten-export) The symbol "AttributeValueType" needs to be exported by the entry point index.d.ts subjectAttributes?: Record; subjectKey: string; } +// @public +export interface IEppoClient { + getAssignment(experimentKey: string): string; +} + // @public export function init(config: IClientConfig): Promise; From 503b0cc999318eb39c48da7e61f9071867cdfc3a Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Tue, 21 Jun 2022 15:05:28 -0700 Subject: [PATCH 5/6] pass subject to assignment function --- docs/js-client-sdk.attributevaluetype.md | 11 -- docs/js-client-sdk.iclientconfig.md | 2 - ...ent-sdk.iclientconfig.subjectattributes.md | 13 -- .../js-client-sdk.iclientconfig.subjectkey.md | 13 -- ...js-client-sdk.ieppoclient.getassignment.md | 4 +- docs/js-client-sdk.ieppoclient.md | 2 +- docs/js-client-sdk.md | 6 - js-client-sdk.api.md | 7 +- src/eppo-client.spec.ts | 38 ++--- src/eppo-client.ts | 44 ++++-- src/experiment/rule.ts | 7 +- src/index.ts | 20 +-- src/rule.ts | 7 +- src/rule_evaluator.spec.ts | 145 ++++++++++++++++++ src/rule_evaluator.ts | 35 +++-- 15 files changed, 222 insertions(+), 132 deletions(-) delete mode 100644 docs/js-client-sdk.attributevaluetype.md delete mode 100644 docs/js-client-sdk.iclientconfig.subjectattributes.md delete mode 100644 docs/js-client-sdk.iclientconfig.subjectkey.md create mode 100644 src/rule_evaluator.spec.ts diff --git a/docs/js-client-sdk.attributevaluetype.md b/docs/js-client-sdk.attributevaluetype.md deleted file mode 100644 index f3f6d2f..0000000 --- a/docs/js-client-sdk.attributevaluetype.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [AttributeValueType](./js-client-sdk.attributevaluetype.md) - -## AttributeValueType type - -Signature: - -```typescript -export declare type AttributeValueType = string | number; -``` diff --git a/docs/js-client-sdk.iclientconfig.md b/docs/js-client-sdk.iclientconfig.md index e64a9f1..3b7219a 100644 --- a/docs/js-client-sdk.iclientconfig.md +++ b/docs/js-client-sdk.iclientconfig.md @@ -18,6 +18,4 @@ export interface IClientConfig | --- | --- | --- | | [apiKey](./js-client-sdk.iclientconfig.apikey.md) | string | Eppo API key | | [baseUrl?](./js-client-sdk.iclientconfig.baseurl.md) | string | (Optional) Base URL of the Eppo API. Clients should use the default setting in most cases. | -| [subjectAttributes?](./js-client-sdk.iclientconfig.subjectattributes.md) | Record<string, [AttributeValueType](./js-client-sdk.attributevaluetype.md)> | (Optional) Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. | -| [subjectKey](./js-client-sdk.iclientconfig.subjectkey.md) | string | An identifier of the experiment subject, for example a user ID. | diff --git a/docs/js-client-sdk.iclientconfig.subjectattributes.md b/docs/js-client-sdk.iclientconfig.subjectattributes.md deleted file mode 100644 index f866e53..0000000 --- a/docs/js-client-sdk.iclientconfig.subjectattributes.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [subjectAttributes](./js-client-sdk.iclientconfig.subjectattributes.md) - -## IClientConfig.subjectAttributes property - -Optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. - -Signature: - -```typescript -subjectAttributes?: Record; -``` diff --git a/docs/js-client-sdk.iclientconfig.subjectkey.md b/docs/js-client-sdk.iclientconfig.subjectkey.md deleted file mode 100644 index faccddd..0000000 --- a/docs/js-client-sdk.iclientconfig.subjectkey.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfig](./js-client-sdk.iclientconfig.md) > [subjectKey](./js-client-sdk.iclientconfig.subjectkey.md) - -## IClientConfig.subjectKey property - -An identifier of the experiment subject, for example a user ID. - -Signature: - -```typescript -subjectKey: string; -``` diff --git a/docs/js-client-sdk.ieppoclient.getassignment.md b/docs/js-client-sdk.ieppoclient.getassignment.md index 52c6748..7eb3b78 100644 --- a/docs/js-client-sdk.ieppoclient.getassignment.md +++ b/docs/js-client-sdk.ieppoclient.getassignment.md @@ -9,14 +9,16 @@ Maps a subject to a variation for a given experiment. Signature: ```typescript -getAssignment(experimentKey: string): string; +getAssignment(subjectKey: string, experimentKey: string, subjectAttributes?: Record): string; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | +| subjectKey | string | an identifier of the experiment subject, for example a user ID. | | experimentKey | string | experiment identifier | +| subjectAttributes | Record<string, any> | (Optional) optional attributes associated with the subject, for example name and email. The subject attributes are used for evaluating any targeting rules tied to the experiment. | Returns: diff --git a/docs/js-client-sdk.ieppoclient.md b/docs/js-client-sdk.ieppoclient.md index 6435dc3..855c409 100644 --- a/docs/js-client-sdk.ieppoclient.md +++ b/docs/js-client-sdk.ieppoclient.md @@ -16,5 +16,5 @@ export interface IEppoClient | Method | Description | | --- | --- | -| [getAssignment(experimentKey)](./js-client-sdk.ieppoclient.getassignment.md) | Maps a subject to a variation for a given experiment. | +| [getAssignment(subjectKey, experimentKey, subjectAttributes)](./js-client-sdk.ieppoclient.getassignment.md) | Maps a subject to a variation for a given experiment. | diff --git a/docs/js-client-sdk.md b/docs/js-client-sdk.md index 35175bf..3f19bc2 100644 --- a/docs/js-client-sdk.md +++ b/docs/js-client-sdk.md @@ -18,9 +18,3 @@ | [IClientConfig](./js-client-sdk.iclientconfig.md) | Configuration used for initializing the Eppo client | | [IEppoClient](./js-client-sdk.ieppoclient.md) | Client for assigning experiment variations. | -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [AttributeValueType](./js-client-sdk.attributevaluetype.md) | | - diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md index da91546..0603598 100644 --- a/js-client-sdk.api.md +++ b/js-client-sdk.api.md @@ -4,9 +4,6 @@ ```ts -// @public (undocumented) -export type AttributeValueType = string | number; - // @public export function getInstance(): IEppoClient; @@ -14,13 +11,11 @@ export function getInstance(): IEppoClient; export interface IClientConfig { apiKey: string; baseUrl?: string; - subjectAttributes?: Record; - subjectKey: string; } // @public export interface IEppoClient { - getAssignment(experimentKey: string): string; + getAssignment(subjectKey: string, experimentKey: string, subjectAttributes?: Record): string; } // @public diff --git a/src/eppo-client.spec.ts b/src/eppo-client.spec.ts index f1ebf07..a4d3b42 100644 --- a/src/eppo-client.spec.ts +++ b/src/eppo-client.spec.ts @@ -12,10 +12,10 @@ import ExperimentConfigurationRequestor from './experiment/experiment-configurat import { IVariation } from './experiment/variation'; import { OperatorType } from './rule'; -import { init } from '.'; +import { getInstance, init } from '.'; describe('EppoClient E2E test', () => { - beforeAll(() => { + beforeAll(async () => { window.sessionStorage.clear(); mock.setup(); mock.get(/randomized_assignment\/config*/, (_req, res) => { @@ -34,6 +34,7 @@ describe('EppoClient E2E test', () => { }); return res.status(200).body(JSON.stringify({ experiments: assignmentConfig })); }); + await init({ apiKey: 'dummy', baseUrl: 'http://127.0.0.1:4000' }); }); afterAll(() => { @@ -51,7 +52,7 @@ describe('EppoClient E2E test', () => { expectedAssignments, }: IAssignmentTestCase) => { console.log(`---- Test Case for ${experiment} Experiment ----`); - const assignments = await getAssignments(subjects, experiment); + const assignments = getAssignments(subjects, experiment); // verify the assingments don't change across test runs (deterministic) expect(assignments).toEqual(expectedAssignments); const expectedVariationSplitPercentage = percentExposure / variations.length; @@ -99,8 +100,8 @@ describe('EppoClient E2E test', () => { a90ea45116d251a43da56e03d3dd7275: 'variant-2', }, }); - const client = new EppoClient('subject-1', mockConfigRequestor); - const assignment = client.getAssignment(experiment); + const client = new EppoClient(mockConfigRequestor); + const assignment = client.getAssignment('subject-1', experiment); expect(assignment).toEqual('variant-2'); }); @@ -141,14 +142,12 @@ describe('EppoClient E2E test', () => { }, ], }); - let client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 9 }); - let assignment = client.getAssignment(experiment); + const client = new EppoClient(mockConfigRequestor); + let assignment = client.getAssignment('subject-1', experiment, { appVersion: 9 }); expect(assignment).toEqual(null); - client = new EppoClient('subject-1', mockConfigRequestor); - assignment = client.getAssignment(experiment); + assignment = client.getAssignment('subject-1', experiment); expect(assignment).toEqual(null); - client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 11 }); - assignment = client.getAssignment(experiment); + assignment = client.getAssignment('subject-1', experiment, { appVersion: 11 }); expect(assignment).toEqual('control'); }); @@ -172,17 +171,10 @@ describe('EppoClient E2E test', () => { expect(percentage).toBeLessThanOrEqual(expectedPercentage + 0.05); } - async function getAssignments(subjects: string[], experiment: string): Promise { - const assignments: string[] = []; - for (const subjectKey of subjects) { - const client = await init({ - apiKey: 'dummy', - baseUrl: 'http://127.0.0.1:4000', - subjectKey, - }); - const assignment = client.getAssignment(experiment); - assignments.push(assignment); - } - return assignments; + function getAssignments(subjects: string[], experiment: string): string[] { + const client = getInstance(); + return subjects.map((subjectKey) => { + return client.getAssignment(subjectKey, experiment); + }); } }); diff --git a/src/eppo-client.ts b/src/eppo-client.ts index c0a3c5b..0a922b5 100644 --- a/src/eppo-client.ts +++ b/src/eppo-client.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto'; import { IExperimentConfiguration } from './experiment/experiment-configuration'; import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor'; -import { Rule } from './experiment/rule'; +import { Rule } from './rule'; import { matchesAnyRule } from './rule_evaluator'; import { getShard, isShardInRange } from './shard'; import { validateNotBlank } from './validation'; @@ -15,48 +15,57 @@ export interface IEppoClient { /** * Maps a subject to a variation for a given experiment. * + * @param subjectKey an identifier of the experiment subject, for example a user ID. * @param experimentKey experiment identifier + * @param subjectAttributes optional attributes associated with the subject, for example name and email. + * The subject attributes are used for evaluating any targeting rules tied to the experiment. * @returns a variation value if the subject is part of the experiment sample, otherwise null * @public */ - getAssignment(experimentKey: string): string; + getAssignment( + subjectKey: string, + experimentKey: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + subjectAttributes?: Record, + ): string; } export default class EppoClient implements IEppoClient { - constructor( - private subjectKey: string, - private configurationRequestor: ExperimentConfigurationRequestor, - private subjectAttributes = {}, - ) {} + constructor(private configurationRequestor: ExperimentConfigurationRequestor) {} - getAssignment(experimentKey: string): string { + getAssignment(subjectKey: string, experimentKey: string, subjectAttributes = {}): string { + validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(experimentKey, 'Invalid argument: experimentKey cannot be blank'); const experimentConfig = this.configurationRequestor.getConfiguration(experimentKey); if ( !experimentConfig?.enabled || - !this.subjectAttributesSatisfyRules(experimentConfig.rules) || - !this.isInExperimentSample(experimentKey, experimentConfig) + !this.subjectAttributesSatisfyRules(subjectAttributes, experimentConfig.rules) || + !this.isInExperimentSample(subjectKey, experimentKey, experimentConfig) ) { return null; } - const override = this.getSubjectVariationOverride(experimentConfig); + const override = this.getSubjectVariationOverride(subjectKey, experimentConfig); if (override) { return override; } const { variations, subjectShards } = experimentConfig; - const shard = getShard(`assignment-${this.subjectKey}-${experimentKey}`, subjectShards); + const shard = getShard(`assignment-${subjectKey}-${experimentKey}`, subjectShards); return variations.find((variation) => isShardInRange(shard, variation.shardRange)).name; } - private subjectAttributesSatisfyRules(rules?: Rule[]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private subjectAttributesSatisfyRules(subjectAttributes?: Record, rules?: Rule[]) { if (!rules || rules.length === 0) { return true; } - return matchesAnyRule(this.subjectAttributes || {}, rules); + return matchesAnyRule(subjectAttributes || {}, rules); } - private getSubjectVariationOverride(experimentConfig: IExperimentConfiguration): string { - const subjectHash = createHash('md5').update(this.subjectKey).digest('hex'); + private getSubjectVariationOverride( + subjectKey: string, + experimentConfig: IExperimentConfiguration, + ): string { + const subjectHash = createHash('md5').update(subjectKey).digest('hex'); return experimentConfig.overrides[subjectHash]; } @@ -66,11 +75,12 @@ export default class EppoClient implements IEppoClient { * Given a hash function output (bucket), check whether the bucket is between 0 and exposure_percent * total_buckets. */ private isInExperimentSample( + subjectKey: string, experimentKey: string, experimentConfig: IExperimentConfiguration, ): boolean { const { percentExposure, subjectShards } = experimentConfig; - const shard = getShard(`exposure-${this.subjectKey}-${experimentKey}`, subjectShards); + const shard = getShard(`exposure-${subjectKey}-${experimentKey}`, subjectShards); return shard <= percentExposure * subjectShards; } } diff --git a/src/experiment/rule.ts b/src/experiment/rule.ts index 9d541ad..8e9ebc0 100644 --- a/src/experiment/rule.ts +++ b/src/experiment/rule.ts @@ -1,17 +1,18 @@ -export type AttributeValueType = string | number; - export enum OperatorType { MATCHES = 'MATCHES', GTE = 'GTE', GT = 'GT', LTE = 'LTE', LT = 'LT', + ONE_OF = 'ONE_OF', + NOT_ONE_OF = 'NOT_ONE_OF', } export interface Condition { operator: OperatorType; attribute: string; - value: AttributeValueType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; } export interface Rule { diff --git a/src/index.ts b/src/index.ts index 4d4b3c1..42b4823 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { BASE_URL, REQUEST_TIMEOUT_MILLIS } from './constants'; import EppoClient, { IEppoClient } from './eppo-client'; import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor'; import HttpClient from './http-client'; -import { AttributeValueType } from './rule'; import { sdkName, sdkVersion } from './sdk-data'; import { EppoSessionStorage } from './storage'; import { validateNotBlank } from './validation'; @@ -19,17 +18,6 @@ export interface IClientConfig { */ apiKey: string; - /** - * An identifier of the experiment subject, for example a user ID. - */ - subjectKey: string; - - /** - * Optional attributes associated with the subject, for example name and email. - * The subject attributes are used for evaluating any targeting rules tied to the experiment. - */ - subjectAttributes?: Record; - /** * Base URL of the Eppo API. * Clients should use the default setting in most cases. @@ -38,7 +26,6 @@ export interface IClientConfig { } export { IEppoClient } from './eppo-client'; -export { AttributeValueType } from './rule'; let clientInstance: IEppoClient = null; @@ -51,7 +38,6 @@ let clientInstance: IEppoClient = null; */ export async function init(config: IClientConfig): Promise { validateNotBlank(config.apiKey, 'API key required'); - validateNotBlank(config.subjectKey, 'subjectKey is required'); const configurationStore = new EppoSessionStorage(); const axiosInstance = axios.create({ baseURL: config.baseUrl || BASE_URL, @@ -66,11 +52,7 @@ export async function init(config: IClientConfig): Promise { configurationStore, httpClient, ); - clientInstance = new EppoClient( - config.subjectKey, - configurationRequestor, - config.subjectAttributes, - ); + clientInstance = new EppoClient(configurationRequestor); if (!configurationStore.isInitialized()) { await configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/rule.ts b/src/rule.ts index 9d541ad..8e9ebc0 100644 --- a/src/rule.ts +++ b/src/rule.ts @@ -1,17 +1,18 @@ -export type AttributeValueType = string | number; - export enum OperatorType { MATCHES = 'MATCHES', GTE = 'GTE', GT = 'GT', LTE = 'LTE', LT = 'LT', + ONE_OF = 'ONE_OF', + NOT_ONE_OF = 'NOT_ONE_OF', } export interface Condition { operator: OperatorType; attribute: string; - value: AttributeValueType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; } export interface Rule { diff --git a/src/rule_evaluator.spec.ts b/src/rule_evaluator.spec.ts new file mode 100644 index 0000000..5618d54 --- /dev/null +++ b/src/rule_evaluator.spec.ts @@ -0,0 +1,145 @@ +import { OperatorType, Rule } from './rule'; +import { matchesAnyRule } from './rule_evaluator'; + +describe('matchesAnyRule', () => { + const ruleWithEmptyConditions: Rule = { + conditions: [], + }; + const numericRule: Rule = { + conditions: [ + { + operator: OperatorType.GTE, + attribute: 'totalSales', + value: 10, + }, + { + operator: OperatorType.LTE, + attribute: 'totalSales', + value: 100, + }, + ], + }; + const ruleWithMatchesCondition: Rule = { + conditions: [ + { + operator: OperatorType.MATCHES, + attribute: 'user_id', + value: '[0-9]+', + }, + ], + }; + + it('returns false if rules array is empty', () => { + const rules: Rule[] = []; + expect(matchesAnyRule({ name: 'my-user' }, rules)).toEqual(false); + }); + + it('returns false if attributes do not match any rules', () => { + const rules = [numericRule]; + expect(matchesAnyRule({ totalSales: 101 }, rules)).toEqual(false); + }); + + it('returns true if attributes match AND conditions', () => { + const rules = [numericRule]; + expect(matchesAnyRule({ totalSales: 100 }, rules)).toEqual(true); + }); + + it('returns false if there is no attribute for the condition', () => { + const rules = [numericRule]; + expect(matchesAnyRule({ unknown: 'test' }, rules)).toEqual(false); + }); + + it('returns true if rules have no conditions', () => { + const rules = [ruleWithEmptyConditions]; + expect(matchesAnyRule({ totalSales: 101 }, rules)).toEqual(true); + }); + + it('returns false if using numeric operator with string', () => { + const rules = [numericRule, ruleWithMatchesCondition]; + expect(matchesAnyRule({ totalSales: 'stringValue' }, rules)).toEqual(false); + expect(matchesAnyRule({ totalSales: '20' }, rules)).toEqual(false); + }); + + it('handles rule with matches operator', () => { + const rules = [ruleWithMatchesCondition]; + expect(matchesAnyRule({ user_id: '14' }, rules)).toEqual(true); + expect(matchesAnyRule({ user_id: 14 }, rules)).toEqual(true); + }); + + it('handles oneOf rule type with boolean', () => { + const oneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.ONE_OF, + value: ['true'], + attribute: 'enabled', + }, + ], + }; + const notOneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.NOT_ONE_OF, + value: ['true'], + attribute: 'enabled', + }, + ], + }; + expect(matchesAnyRule({ enabled: true }, [oneOfRule])).toEqual(true); + expect(matchesAnyRule({ enabled: false }, [oneOfRule])).toEqual(false); + expect(matchesAnyRule({ enabled: true }, [notOneOfRule])).toEqual(false); + expect(matchesAnyRule({ enabled: false }, [notOneOfRule])).toEqual(true); + }); + + it('handles oneOf rule type with string', () => { + const oneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.ONE_OF, + value: ['user1', 'user2'], + attribute: 'userId', + }, + ], + }; + const notOneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.NOT_ONE_OF, + value: ['user14'], + attribute: 'userId', + }, + ], + }; + expect(matchesAnyRule({ userId: 'user1' }, [oneOfRule])).toEqual(true); + expect(matchesAnyRule({ userId: 'user2' }, [oneOfRule])).toEqual(true); + expect(matchesAnyRule({ userId: 'user3' }, [oneOfRule])).toEqual(false); + expect(matchesAnyRule({ userId: 'user14' }, [notOneOfRule])).toEqual(false); + expect(matchesAnyRule({ userId: 'user15' }, [notOneOfRule])).toEqual(true); + }); + + it('handles oneOf rule with number', () => { + const oneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.ONE_OF, + value: ['1', '2'], + attribute: 'userId', + }, + ], + }; + const notOneOfRule: Rule = { + conditions: [ + { + operator: OperatorType.NOT_ONE_OF, + value: ['14'], + attribute: 'userId', + }, + ], + }; + expect(matchesAnyRule({ userId: 1 }, [oneOfRule])).toEqual(true); + expect(matchesAnyRule({ userId: '2' }, [oneOfRule])).toEqual(true); + expect(matchesAnyRule({ userId: 3 }, [oneOfRule])).toEqual(false); + expect(matchesAnyRule({ userId: 14 }, [notOneOfRule])).toEqual(false); + expect(matchesAnyRule({ userId: '15' }, [notOneOfRule])).toEqual(true); + }); +}); diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts index ead3942..08bc604 100644 --- a/src/rule_evaluator.ts +++ b/src/rule_evaluator.ts @@ -1,9 +1,7 @@ -import { Condition, OperatorType, Rule, AttributeValueType } from './rule'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Condition, OperatorType, Rule } from './rule'; -export function matchesAnyRule( - subjectAttributes: Record, - rules: Rule[], -): boolean { +export function matchesAnyRule(subjectAttributes: Record, rules: Rule[]): boolean { for (const rule of rules) { if (matchesRule(subjectAttributes, rule)) { return true; @@ -12,24 +10,21 @@ export function matchesAnyRule( return false; } -function matchesRule(subjectAttributes: Record, rule: Rule): boolean { +function matchesRule(subjectAttributes: Record, rule: Rule): boolean { const conditionEvaluations = evaluateRuleConditions(subjectAttributes, rule.conditions); return !conditionEvaluations.includes(false); } function evaluateRuleConditions( - subjectAttributes: Record, + subjectAttributes: Record, conditions: Condition[], ): boolean[] { return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)); } -function evaluateCondition( - subjectAttributes: Record, - condition: Condition, -): boolean { +function evaluateCondition(subjectAttributes: Record, condition: Condition): boolean { const value = subjectAttributes[condition.attribute]; - if (value) { + if (value != null) { switch (condition.operator) { case OperatorType.GTE: return compareNumber(value, condition.value, (a, b) => a >= b); @@ -41,14 +36,26 @@ function evaluateCondition( return compareNumber(value, condition.value, (a, b) => a < b); case OperatorType.MATCHES: return new RegExp(condition.value as string).test(value as string); + case OperatorType.ONE_OF: + return isOneOf(value, condition.value); + case OperatorType.NOT_ONE_OF: + return isNotOneOf(value, condition.value); } } return false; } +function isOneOf(attributeValue: any, conditionValue: string[]) { + return conditionValue.includes(attributeValue.toString()); +} + +function isNotOneOf(attributeValue: any, conditionValue: string[]) { + return !conditionValue.includes(attributeValue.toString()); +} + function compareNumber( - attributeValue: AttributeValueType, - conditionValue: AttributeValueType, + attributeValue: any, + conditionValue: any, compareFn: (a: number, b: number) => boolean, ) { return ( From 6ae6352ccecafa435441d336ec21d3f07bde65d0 Mon Sep 17 00:00:00 2001 From: Peter Loomis Date: Tue, 21 Jun 2022 15:07:09 -0700 Subject: [PATCH 6/6] increase request timeout to 5 seconds --- src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants.ts b/src/constants.ts index 354518d..4d4f1d1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,2 +1,2 @@ -export const REQUEST_TIMEOUT_MILLIS = 1000; +export const REQUEST_TIMEOUT_MILLIS = 5000; export const BASE_URL = 'https://eppo.cloud/api';