From 1ec2efe7bcdc4245c83543d00bbfc966dbd76cd2 Mon Sep 17 00:00:00 2001 From: adams85 <31276480+adams85@users.noreply.github.com> Date: Mon, 8 Jan 2024 15:54:20 +0100 Subject: [PATCH] Minor improvements and fixes (#100) * Don't swallow exceptions when config json deserialization fails * Add types describing the config json schema and use them in the config model builder logic to eliminate loose typing + correct minor errors in the config model types * Fix additional maxInitWaitTime tests by adding a bit of tolerance to timing checks * Fix getKeyAndValueAsync so it doesn't return an unsupported setting value * Bump version * Run tests on Node 20 * Improve message of error 1103 * Fix another occasionally failing test (increase tolerance of timing checks) * Correct the generic parameter constraint of IEvaluationDetails and SettingKey --- .github/workflows/common-js-ci.yml | 2 +- package-lock.json | 4 +- package.json | 2 +- samples/deno-sandbox/import_map.json | 1 + src/ConfigCatClient.ts | 16 +- src/ConfigCatLogger.ts | 7 +- src/ConfigJson.ts | 258 +++++++++++++++++++++++++++ src/ConfigServiceBase.ts | 23 +-- src/EvaluateLogBuilder.ts | 2 +- src/ProjectConfig.ts | 215 ++++++---------------- src/RolloutEvaluator.ts | 6 +- src/index.ts | 2 +- test/ConfigCatCacheTests.ts | 4 +- test/ConfigCatClientTests.ts | 28 +-- test/ConfigServiceBaseTests.ts | 4 +- test/DataGovernanceTests.ts | 6 +- 16 files changed, 371 insertions(+), 209 deletions(-) create mode 100644 src/ConfigJson.ts diff --git a/.github/workflows/common-js-ci.yml b/.github/workflows/common-js-ci.yml index ef78188..0049ae1 100644 --- a/.github/workflows/common-js-ci.yml +++ b/.github/workflows/common-js-ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: [ 14, 16, 18 ] + node: [ 14, 16, 18, 20 ] os: [ macos-latest, ubuntu-latest, windows-latest ] fail-fast: false name: Test [${{ matrix.os }}, Node ${{ matrix.node }}] diff --git a/package-lock.json b/package-lock.json index 69f65d7..496bf87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "configcat-common", - "version": "9.0.0", + "version": "9.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "configcat-common", - "version": "9.0.0", + "version": "9.1.0", "license": "MIT", "dependencies": { "tslib": "^2.4.1" diff --git a/package.json b/package.json index 86df825..cf5739a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "configcat-common", - "version": "9.0.0", + "version": "9.1.0", "description": "ConfigCat is a configuration as a service that lets you manage your features and configurations without actually deploying new code.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/samples/deno-sandbox/import_map.json b/samples/deno-sandbox/import_map.json index 3bbe107..747f83e 100644 --- a/samples/deno-sandbox/import_map.json +++ b/samples/deno-sandbox/import_map.json @@ -10,6 +10,7 @@ "../../src/ConfigCatClientOptions": "../../src/ConfigCatClientOptions.ts", "../../src/ConfigCatLogger": "../../src/ConfigCatLogger.ts", "../../src/ConfigFetcher": "../../src/ConfigFetcher.ts", + "../../src/ConfigJson": "../../src/ConfigJson.ts", "../../src/ConfigServiceBase": "../../src/ConfigServiceBase.ts", "../../src/DefaultEventEmitter": "../../src/DefaultEventEmitter.ts", "../../src/EvaluateLogBuilder": "../../src/EvaluateLogBuilder.ts", diff --git a/src/ConfigCatClient.ts b/src/ConfigCatClient.ts index ec9fad2..c2418bb 100644 --- a/src/ConfigCatClient.ts +++ b/src/ConfigCatClient.ts @@ -14,7 +14,7 @@ import { ManualPollConfigService } from "./ManualPollConfigService"; import { getWeakRefStub, isWeakRefAvailable } from "./Polyfills"; import type { IConfig, PercentageOption, ProjectConfig, Setting, SettingValue } from "./ProjectConfig"; import type { IEvaluationDetails, IRolloutEvaluator, SettingTypeOf } from "./RolloutEvaluator"; -import { RolloutEvaluator, checkSettingsAvailable, evaluate, evaluateAll, evaluationDetailsFromDefaultValue, getTimestampAsDate, isAllowedValue } from "./RolloutEvaluator"; +import { RolloutEvaluator, checkSettingsAvailable, evaluate, evaluateAll, evaluationDetailsFromDefaultValue, getTimestampAsDate, handleInvalidReturnValue, isAllowedValue } from "./RolloutEvaluator"; import type { User } from "./User"; import { errorToString, isArray, throwError } from "./Utils"; @@ -496,7 +496,7 @@ export class ConfigCatClient implements IConfigCatClient { for (const [settingKey, setting] of Object.entries(settings)) { if (variationId === setting.variationId) { - return new SettingKeyValue(settingKey, setting.value); + return new SettingKeyValue(settingKey, ensureAllowedValue(setting.value)); } const targetingRules = settings[settingKey].targetingRules; @@ -507,12 +507,12 @@ export class ConfigCatClient implements IConfigCatClient { for (let j = 0; j < then.length; j++) { const percentageOption: PercentageOption = then[j]; if (variationId === percentageOption.variationId) { - return new SettingKeyValue(settingKey, percentageOption.value); + return new SettingKeyValue(settingKey, ensureAllowedValue(percentageOption.value)); } } } else if (variationId === then.variationId) { - return new SettingKeyValue(settingKey, then.value); + return new SettingKeyValue(settingKey, ensureAllowedValue(then.value)); } } } @@ -522,7 +522,7 @@ export class ConfigCatClient implements IConfigCatClient { for (let i = 0; i < percentageOptions.length; i++) { const percentageOption: PercentageOption = percentageOptions[i]; if (variationId === percentageOption.variationId) { - return new SettingKeyValue(settingKey, percentageOption.value); + return new SettingKeyValue(settingKey, ensureAllowedValue(percentageOption.value)); } } } @@ -759,7 +759,7 @@ class Snapshot implements IConfigCatClientSnapshot { } /** Setting key-value pair. */ -export class SettingKeyValue { +export class SettingKeyValue { constructor( public settingKey: string, public settingValue: TValue) { } @@ -794,6 +794,10 @@ function ensureAllowedDefaultValue(value: SettingValue): void { } } +function ensureAllowedValue(value: NonNullable): NonNullable { + return isAllowedValue(value) ? value : handleInvalidReturnValue(value); +} + /* GC finalization support */ // Defines the interface of the held value which is passed to ConfigCatClient.finalize by FinalizationRegistry. diff --git a/src/ConfigCatLogger.ts b/src/ConfigCatLogger.ts index aa23a4a..12f1861 100644 --- a/src/ConfigCatLogger.ts +++ b/src/ConfigCatLogger.ts @@ -195,7 +195,7 @@ export class LoggerWrapper implements IConfigCatLogger { fetchFailedDueToUnexpectedError(ex: any): LogMessage { return this.log( LogLevel.Error, 1103, - "Unexpected error occurred while trying to fetch config JSON.", + "Unexpected error occurred while trying to fetch config JSON. It is most likely due to a local network issue. Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP.", ex ); } @@ -207,10 +207,11 @@ export class LoggerWrapper implements IConfigCatLogger { ); } - fetchReceived200WithInvalidBody(): LogMessage { + fetchReceived200WithInvalidBody(ex: any): LogMessage { return this.log( LogLevel.Error, 1105, - "Fetching config JSON was successful but the HTTP response content was invalid." + "Fetching config JSON was successful but the HTTP response content was invalid.", + ex ); } diff --git a/src/ConfigJson.ts b/src/ConfigJson.ts new file mode 100644 index 0000000..03e7a39 --- /dev/null +++ b/src/ConfigJson.ts @@ -0,0 +1,258 @@ +/** + * The ConfigCat config_v6.json schema that is used by the ConfigCat SDKs, described using TypeScript types. + */ + +export type Config = { + /** + * Preferences of the config.json, mostly for controlling the redirection behaviour of the SDK. + */ + p: Preferences; + /** + * Segment definitions for re-using segment rules in targeting rules. + */ + s?: Segment[]; + /** + * Setting definitions. + */ + f?: { [key: string]: SettingUnion }; +} + +export type Preferences = { + /** + * The redirect mode that should be used in case the data governance mode is wrongly configured. + */ + r: RedirectMode; + /** + * The base url from where the config.json is intended to be downloaded. + */ + u: string; + /** + * The salt that, combined with the feature flag key or segment name, is used to hash values for sensitive text comparisons. + */ + s: string; +} + +export type Segment = { + n: string; + r: [UserConditionUnion, ...UserConditionUnion[]]; +} + +export type SettingUnion = { [K in SettingType]: Setting }[SettingType]; + +export type Setting = { + t: TSetting; + /** + * The percentage rule evaluation will hash this attribute of the User object to calculate the buckets. + */ + a?: string; + r?: TargetingRule[]; + p?: PercentageOption[]; +} & ServedValue; + +export type TargetingRule = { + c: [ConditionUnion, ...ConditionUnion[]]; +} & ( + { s: ServedValue; p?: never } + | { p: PercentageOption[]; s?: never } +) + +export type ConditionUnion = + { u: UserConditionUnion; p?: never; s?: never } + | { p: PrerequisiteFlagCondition; u?: never; s?: never } + | { s: SegmentCondition; u?: never; p?: never } + +export type PercentageOption = { + p: number; +} & ServedValue; + +export type SettingValue = { + [SettingType.Boolean]: { b: boolean; s?: never; i?: never; d?: never }; + [SettingType.String]: { s: string; b?: never; i?: never; d?: never }; + [SettingType.Int]: { i: number; b?: never; s?: never; d?: never }; + [SettingType.Double]: { d: number; b?: never; s?: never; i?: never }; +}[TSetting]; + +export type UserConditionUnion = { [K in UserComparator]: UserCondition }[UserComparator]; + +export type UserCondition = { + /** + * The attribute of the user object that should be used to evalaute this rule. + */ + a: string; + c: TComparator; +} & UserConditionComparisonValue + +export type UserConditionComparisonValue = { + [UserComparator.IsOneOf]: UserConditionStringListComparisonValue; + [UserComparator.IsNotOneOf]: UserConditionStringListComparisonValue; + [UserComparator.ContainsAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.NotContainsAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SemVerIsOneOf]: UserConditionStringListComparisonValue; + [UserComparator.SemVerIsNotOneOf]: UserConditionStringListComparisonValue; + [UserComparator.SemVerLess]: UserConditionStringComparisonValue; + [UserComparator.SemVerLessOrEquals]: UserConditionStringComparisonValue; + [UserComparator.SemVerGreater]: UserConditionStringComparisonValue; + [UserComparator.SemVerGreaterOrEquals]: UserConditionStringComparisonValue; + [UserComparator.NumberEquals]: UserConditionNumberComparisonValue; + [UserComparator.NumberNotEquals]: UserConditionNumberComparisonValue; + [UserComparator.NumberLess]: UserConditionNumberComparisonValue; + [UserComparator.NumberLessOrEquals]: UserConditionNumberComparisonValue; + [UserComparator.NumberGreater]: UserConditionNumberComparisonValue; + [UserComparator.NumberGreaterOrEquals]: UserConditionNumberComparisonValue; + [UserComparator.SensitiveIsOneOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveIsNotOneOf]: UserConditionStringListComparisonValue; + [UserComparator.DateTimeBefore]: UserConditionNumberComparisonValue; + [UserComparator.DateTimeAfter]: UserConditionNumberComparisonValue; + [UserComparator.SensitiveTextEquals]: UserConditionStringComparisonValue; + [UserComparator.SensitiveTextNotEquals]: UserConditionStringComparisonValue; + [UserComparator.SensitiveTextStartsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveTextNotStartsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveTextEndsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveTextNotEndsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveArrayContainsAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.SensitiveArrayNotContainsAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.TextEquals]: UserConditionStringComparisonValue; + [UserComparator.TextNotEquals]: UserConditionStringComparisonValue; + [UserComparator.TextStartsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.TextNotStartsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.TextEndsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.TextNotEndsWithAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.ArrayContainsAnyOf]: UserConditionStringListComparisonValue; + [UserComparator.ArrayNotContainsAnyOf]: UserConditionStringListComparisonValue; +}[TComparator]; + +export type UserConditionStringComparisonValue = { s: string; d?: never; l?: never }; +export type UserConditionNumberComparisonValue = { d: number; s?: never; l?: never }; +export type UserConditionStringListComparisonValue = { l: string[]; s?: never; d?: never }; + +export type PrerequisiteFlagCondition = { + /** + * The key of the prerequisite flag. + */ + f: string; + c: PrerequisiteFlagComparator; + v: SettingValue; +} + +export type SegmentCondition = { + /** + * The zero-based index of the segment. + */ + s: number; + c: SegmentComparator; +} + +export type ServedValue = { + v: SettingValue; + i: string; +} + +export enum RedirectMode { + No = 0, + Should = 1, + Force = 2, +} + +/** Setting type. */ +export enum SettingType { + /** On/off type (feature flag). */ + Boolean = 0, + /** Text type. */ + String = 1, + /** Whole number type. */ + Int = 2, + /** Decimal number type. */ + Double = 3, +} + +/** User Object attribute comparison operator used during the evaluation process. */ +export enum UserComparator { + /** IS ONE OF (cleartext) - It matches when the comparison attribute is equal to any of the comparison values. */ + IsOneOf = 0, + /** IS NOT ONE OF (cleartext) - It matches when the comparison attribute is not equal to any of the comparison values. */ + IsNotOneOf = 1, + /** CONTAINS ANY OF (cleartext) - It matches when the comparison attribute contains any comparison values as a substring. */ + ContainsAnyOf = 2, + /** NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute does not contain any comparison values as a substring. */ + NotContainsAnyOf = 3, + /** IS ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is equal to any of the comparison values. */ + SemVerIsOneOf = 4, + /** IS NOT ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is not equal to any of the comparison values. */ + SemVerIsNotOneOf = 5, + /** < (semver) - It matches when the comparison attribute interpreted as a semantic version is less than the comparison value. */ + SemVerLess = 6, + /** <= (semver) - It matches when the comparison attribute interpreted as a semantic version is less than or equal to the comparison value. */ + SemVerLessOrEquals = 7, + /** > (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than the comparison value. */ + SemVerGreater = 8, + /** >= (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than or equal to the comparison value. */ + SemVerGreaterOrEquals = 9, + /** = (number) - It matches when the comparison attribute interpreted as a decimal number is equal to the comparison value. */ + NumberEquals = 10, + /** != (number) - It matches when the comparison attribute interpreted as a decimal number is not equal to the comparison value. */ + NumberNotEquals = 11, + /** < (number) - It matches when the comparison attribute interpreted as a decimal number is less than the comparison value. */ + NumberLess = 12, + /** <= (number) - It matches when the comparison attribute interpreted as a decimal number is less than or equal to the comparison value. */ + NumberLessOrEquals = 13, + /** > (number) - It matches when the comparison attribute interpreted as a decimal number is greater than the comparison value. */ + NumberGreater = 14, + /** >= (number) - It matches when the comparison attribute interpreted as a decimal number is greater than or equal to the comparison value. */ + NumberGreaterOrEquals = 15, + /** IS ONE OF (hashed) - It matches when the comparison attribute is equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveIsOneOf = 16, + /** IS NOT ONE OF (hashed) - It matches when the comparison attribute is not equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveIsNotOneOf = 17, + /** BEFORE (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is less than the comparison value. */ + DateTimeBefore = 18, + /** AFTER (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is greater than the comparison value. */ + DateTimeAfter = 19, + /** EQUALS (hashed) - It matches when the comparison attribute is equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextEquals = 20, + /** NOT EQUALS (hashed) - It matches when the comparison attribute is not equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextNotEquals = 21, + /** STARTS WITH ANY OF (hashed) - It matches when the comparison attribute starts with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextStartsWithAnyOf = 22, + /** NOT STARTS WITH ANY OF (hashed) - It matches when the comparison attribute does not start with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextNotStartsWithAnyOf = 23, + /** ENDS WITH ANY OF (hashed) - It matches when the comparison attribute ends with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextEndsWithAnyOf = 24, + /** NOT ENDS WITH ANY OF (hashed) - It matches when the comparison attribute does not end with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveTextNotEndsWithAnyOf = 25, + /** ARRAY CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveArrayContainsAnyOf = 26, + /** ARRAY NOT CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ + SensitiveArrayNotContainsAnyOf = 27, + /** EQUALS (cleartext) - It matches when the comparison attribute is equal to the comparison value. */ + TextEquals = 28, + /** NOT EQUALS (cleartext) - It matches when the comparison attribute is not equal to the comparison value. */ + TextNotEquals = 29, + /** STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute starts with any of the comparison values. */ + TextStartsWithAnyOf = 30, + /** NOT STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute does not start with any of the comparison values. */ + TextNotStartsWithAnyOf = 31, + /** ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute ends with any of the comparison values. */ + TextEndsWithAnyOf = 32, + /** NOT ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute does not end with any of the comparison values. */ + TextNotEndsWithAnyOf = 33, + /** ARRAY CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values. */ + ArrayContainsAnyOf = 34, + /** ARRAY NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values. */ + ArrayNotContainsAnyOf = 35, +} + +/** Prerequisite flag comparison operator used during the evaluation process. */ +export enum PrerequisiteFlagComparator { + /** EQUALS - It matches when the evaluated value of the specified prerequisite flag is equal to the comparison value. */ + Equals = 0, + /** NOT EQUALS - It matches when the evaluated value of the specified prerequisite flag is not equal to the comparison value. */ + NotEquals = 1 +} + +/** Segment comparison operator used during the evaluation process. */ +export enum SegmentComparator { + /** IS IN SEGMENT - It matches when the conditions of the specified segment are evaluated to true. */ + IsIn = 0, + /** IS NOT IN SEGMENT - It matches when the conditions of the specified segment are evaluated to false. */ + IsNotIn = 1, +} diff --git a/src/ConfigServiceBase.ts b/src/ConfigServiceBase.ts index b996545..154e0bc 100644 --- a/src/ConfigServiceBase.ts +++ b/src/ConfigServiceBase.ts @@ -1,7 +1,8 @@ import type { OptionsBase } from "./ConfigCatClientOptions"; import type { FetchErrorCauses, IConfigFetcher, IFetchResponse } from "./ConfigFetcher"; import { FetchError, FetchResult, FetchStatus } from "./ConfigFetcher"; -import { Config, ProjectConfig, RedirectMode } from "./ProjectConfig"; +import { RedirectMode } from "./ConfigJson"; +import { Config, ProjectConfig } from "./ProjectConfig"; /** Contains the result of an `IConfigCatClient.forceRefresh` or `IConfigCatClient.forceRefreshAsync` operation. */ export class RefreshResult { @@ -153,18 +154,18 @@ export abstract class ConfigServiceBase { let errorMessage: string; try { - const [response, config] = await this.fetchRequestAsync(lastConfig.httpETag ?? null); + const [response, configOrError] = await this.fetchRequestAsync(lastConfig.httpETag ?? null); switch (response.statusCode) { case 200: // OK - if (!config) { - errorMessage = options.logger.fetchReceived200WithInvalidBody().toString(); + if (!(configOrError instanceof Config)) { + errorMessage = options.logger.fetchReceived200WithInvalidBody(configOrError).toString(); options.logger.debug(`ConfigServiceBase.fetchLogicAsync(): ${response.statusCode} ${response.reasonPhrase} was received but the HTTP response content was invalid. Returning null.`); - return FetchResult.error(lastConfig, errorMessage); + return FetchResult.error(lastConfig, errorMessage, configOrError); } options.logger.debug("ConfigServiceBase.fetchLogicAsync(): fetch was successful. Returning new config."); - return FetchResult.success(new ProjectConfig(response.body, config, ProjectConfig.generateTimestamp(), response.eTag)); + return FetchResult.success(new ProjectConfig(response.body, configOrError, ProjectConfig.generateTimestamp(), response.eTag)); case 304: // Not Modified if (!lastConfig) { @@ -198,7 +199,7 @@ export abstract class ConfigServiceBase { } } - private async fetchRequestAsync(lastETag: string | null, maxRetryCount = 2): Promise<[IFetchResponse, Config?]> { + private async fetchRequestAsync(lastETag: string | null, maxRetryCount = 2): Promise<[IFetchResponse, (Config | any)?]> { const options = this.options; options.logger.debug("ConfigServiceBase.fetchRequestAsync() - called."); @@ -212,16 +213,16 @@ export abstract class ConfigServiceBase { if (!response.body) { options.logger.debug("ConfigServiceBase.fetchRequestAsync(): no response body."); - return [response]; + return [response, new Error("No response body.")]; } let config: Config; try { - config = new Config(JSON.parse(response.body)); + config = Config.deserialize(response.body); } - catch { + catch (err) { options.logger.debug("ConfigServiceBase.fetchRequestAsync(): invalid response body."); - return [response]; + return [response, err]; } const preferences = config.preferences; diff --git a/src/EvaluateLogBuilder.ts b/src/EvaluateLogBuilder.ts index 7d3df36..e9e5e0c 100644 --- a/src/EvaluateLogBuilder.ts +++ b/src/EvaluateLogBuilder.ts @@ -1,5 +1,5 @@ +import { PrerequisiteFlagComparator, SegmentComparator, UserComparator } from "./ConfigJson"; import type { PrerequisiteFlagCondition, SegmentCondition, SettingValue, TargetingRule, UserCondition, UserConditionUnion } from "./ProjectConfig"; -import { PrerequisiteFlagComparator, SegmentComparator, UserComparator } from "./ProjectConfig"; import { isAllowedValue } from "./RolloutEvaluator"; import { formatStringList, isArray } from "./Utils"; diff --git a/src/ProjectConfig.ts b/src/ProjectConfig.ts index 8af4627..00860c5 100644 --- a/src/ProjectConfig.ts +++ b/src/ProjectConfig.ts @@ -1,3 +1,5 @@ +import type * as ConfigJson from "./ConfigJson"; +import type { PrerequisiteFlagComparator, RedirectMode, SegmentComparator, SettingType, UserComparator } from "./ConfigJson"; import type { WellKnownUserObjectAttribute } from "./User"; export class ProjectConfig { @@ -69,12 +71,7 @@ export class ProjectConfig { let config: Config | undefined; let configJson: string | undefined; if (slice.length > 0) { - try { - config = new Config(JSON.parse(slice)); - } - catch { - throw new Error("Invalid config JSON content: " + slice); - } + config = Config.deserialize(slice); configJson = slice; } @@ -93,13 +90,21 @@ export interface IConfig { } export class Config implements IConfig { + static deserialize(configJson: string): Config { + const configJsonParsed = JSON.parse(configJson); + if (typeof configJsonParsed !== "object" || !configJsonParsed) { + throw new Error("Invalid config JSON content:" + configJson); + } + return new Config(configJsonParsed); + } + readonly preferences: Preferences | undefined; readonly segments: ReadonlyArray; readonly settings: Readonly<{ [key: string]: SettingUnion }>; - constructor(json: any) { + constructor(json: Partial) { this.preferences = json.p != null ? new Preferences(json.p) : void 0; - this.segments = json.s?.map((item: any) => new Segment(item)) ?? []; + this.segments = json.s?.map(item => new Segment(item)) ?? []; this.settings = json.f != null ? Object.fromEntries(Object.entries(json.f).map(([key, value]) => [key, new Setting(value, this) as SettingUnion])) : {}; @@ -108,18 +113,12 @@ export class Config implements IConfig { get salt(): string | undefined { return this.preferences?.salt; } } -export enum RedirectMode { - No = 0, - Should = 1, - Force = 2, -} - export class Preferences { readonly baseUrl: string | undefined; readonly redirectMode: RedirectMode | undefined; readonly salt: string | undefined; - constructor(json: any) { + constructor(json: ConfigJson.Preferences) { this.baseUrl = json.u; this.redirectMode = json.r; this.salt = json.s; @@ -138,24 +137,12 @@ export class Segment implements ISegment { readonly name: string; readonly conditions: ReadonlyArray; - constructor(json: any) { + constructor(json: ConfigJson.Segment) { this.name = json.n; - this.conditions = json.r?.map((item: any) => new UserCondition(item)) ?? []; + this.conditions = json.r?.map(item => new UserCondition(item) as UserConditionUnion) ?? []; } } -/** Setting type. */ -export enum SettingType { - /** On/off type (feature flag). */ - Boolean = 0, - /** Text type. */ - String = 1, - /** Whole number type. */ - Int = 2, - /** Decimal number type. */ - Double = 3, -} - export type SettingTypeMap = { [SettingType.Boolean]: boolean; [SettingType.String]: string; @@ -168,19 +155,19 @@ export type SettingValue = SettingTypeMap[SettingType] | null | undefined; export type VariationIdValue = string | null | undefined; /** A model object which contains a setting value along with related data. */ -export interface ISettingValueContainer = NonNullable> { +export interface ISettingValueContainer { /** Setting value. */ - readonly value: TValue; + readonly value: SettingTypeMap[TSetting]; /** Variation ID. */ readonly variationId?: NonNullable; } -export class SettingValueContainer = NonNullable> implements ISettingValueContainer { - readonly value: TValue; +export class SettingValueContainer implements ISettingValueContainer { + readonly value: SettingTypeMap[TSetting]; // can also store an unsupported value of any type for internal use, however such values should never be exposed to the user! readonly variationId?: NonNullable; - constructor(json: any, hasUnwrappedValue = false) { - this.value = !hasUnwrappedValue ? unwrapSettingValue(json.v) : json.v; + constructor(json: ConfigJson.ServedValue, hasUnwrappedValue = false) { + this.value = (!hasUnwrappedValue ? unwrapSettingValue(json.v) : json.v) as SettingTypeMap[TSetting]; this.variationId = json.i; } } @@ -191,33 +178,33 @@ export class SettingValueContainer = No export type ISettingUnion = { [K in SettingType]: ISetting }[SettingType]; /** Feature flag or setting. */ -export interface ISetting extends ISettingValueContainer { +export interface ISetting extends ISettingValueContainer { /** Setting type. */ - readonly type: T; + readonly type: TSetting; /** The User Object attribute which serves as the basis of percentage options evaluation. */ readonly percentageOptionsAttribute: string; /** The array of targeting rules (where there is a logical OR relation between the items). */ - readonly targetingRules: ReadonlyArray; + readonly targetingRules: ReadonlyArray>; /** The array of percentage options. */ - readonly percentageOptions: ReadonlyArray; + readonly percentageOptions: ReadonlyArray>; } export type SettingUnion = { [K in SettingType]: Setting }[SettingType]; -export class Setting extends SettingValueContainer implements ISetting { - readonly type: T; +export class Setting extends SettingValueContainer implements ISetting { + readonly type: TSetting; readonly percentageOptionsAttribute: string; - readonly targetingRules: ReadonlyArray>; - readonly percentageOptions: ReadonlyArray>; + readonly targetingRules: ReadonlyArray>; + readonly percentageOptions: ReadonlyArray>; readonly configJsonSalt: string; - constructor(json: any, config?: Config) { + constructor(json: ConfigJson.Setting, config?: Config) { super(json, json.t < 0); this.type = json.t; const identifierAttribute: WellKnownUserObjectAttribute = "Identifier"; this.percentageOptionsAttribute = json.a ?? identifierAttribute; - this.targetingRules = json.r?.map((item: any) => new TargetingRule(item, config!)) ?? []; - this.percentageOptions = json.p?.map((item: any) => new PercentageOption(item)) ?? []; + this.targetingRules = json.r?.map(item => new TargetingRule(item, config!)) ?? []; + this.percentageOptions = json.p?.map(item => new PercentageOption(item)) ?? []; this.configJsonSalt = config?.salt ?? ""; } @@ -225,44 +212,44 @@ export class Setting extends SettingValueCo return new Setting({ t: -1, // this is not a defined SettingType value, we only use it internally (will never expose it to the consumer) v: value, - }); + } as unknown as ConfigJson.Setting); } } /** Describes a targeting rule. */ -export interface ITargetingRule = NonNullable> { +export interface ITargetingRule { /** The array of conditions that are combined with the AND logical operator. (The IF part of the targeting rule.) */ readonly conditions: ReadonlyArray; /** The simple value or the array of percentage options associated with the targeting rule. (The THEN part of the targeting rule.) */ - readonly then: ISettingValueContainer | ReadonlyArray>; + readonly then: ISettingValueContainer | ReadonlyArray>; } -export class TargetingRule = NonNullable> implements ITargetingRule { +export class TargetingRule implements ITargetingRule { readonly conditions: ReadonlyArray; - readonly then: SettingValueContainer | ReadonlyArray>; + readonly then: SettingValueContainer | ReadonlyArray>; - constructor(json: any, config: Config) { - this.conditions = json.c?.map((item: any) => - item.u != null ? new UserCondition(item.u) : + constructor(json: ConfigJson.TargetingRule, config: Config) { + this.conditions = json.c?.map(item => + item.u != null ? new UserCondition(item.u) as UserConditionUnion : item.p != null ? new PrerequisiteFlagCondition(item.p) : item.s != null ? new SegmentCondition(item.s, config) : - void 0) ?? []; + void 0 as unknown as ConditionUnion) ?? []; this.then = json.p != null - ? json.p.map((item: any) => new PercentageOption(item)) - : new SettingValueContainer(json.s); + ? json.p.map(item => new PercentageOption(item)) + : new SettingValueContainer(json.s); } } /** Represents a percentage option. */ -export interface IPercentageOption = NonNullable> extends ISettingValueContainer { +export interface IPercentageOption extends ISettingValueContainer { /** A number between 0 and 100 that represents a randomly allocated fraction of the users. */ readonly percentage: number; } -export class PercentageOption = NonNullable> extends SettingValueContainer implements IPercentageOption { +export class PercentageOption extends SettingValueContainer implements IPercentageOption { readonly percentage: number; - constructor(json: any) { + constructor(json: ConfigJson.PercentageOption) { super(json); this.percentage = json.p; } @@ -277,89 +264,13 @@ export type ConditionTypeMap = { export type IConditionUnion = ConditionTypeMap[keyof ConditionTypeMap]; /** Represents a condition. */ -export interface ICondition { +export interface ICondition { /** The type of the condition. */ - readonly type: T; + readonly type: TCondition; } export type ConditionUnion = UserConditionUnion | PrerequisiteFlagCondition | SegmentCondition; -/** User Object attribute comparison operator used during the evaluation process. */ -export enum UserComparator { - /** IS ONE OF (cleartext) - It matches when the comparison attribute is equal to any of the comparison values. */ - IsOneOf = 0, - /** IS NOT ONE OF (cleartext) - It matches when the comparison attribute is not equal to any of the comparison values. */ - IsNotOneOf = 1, - /** CONTAINS ANY OF (cleartext) - It matches when the comparison attribute contains any comparison values as a substring. */ - ContainsAnyOf = 2, - /** NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute does not contain any comparison values as a substring. */ - NotContainsAnyOf = 3, - /** IS ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is equal to any of the comparison values. */ - SemVerIsOneOf = 4, - /** IS NOT ONE OF (semver) - It matches when the comparison attribute interpreted as a semantic version is not equal to any of the comparison values. */ - SemVerIsNotOneOf = 5, - /** < (semver) - It matches when the comparison attribute interpreted as a semantic version is less than the comparison value. */ - SemVerLess = 6, - /** <= (semver) - It matches when the comparison attribute interpreted as a semantic version is less than or equal to the comparison value. */ - SemVerLessOrEquals = 7, - /** > (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than the comparison value. */ - SemVerGreater = 8, - /** >= (semver) - It matches when the comparison attribute interpreted as a semantic version is greater than or equal to the comparison value. */ - SemVerGreaterOrEquals = 9, - /** = (number) - It matches when the comparison attribute interpreted as a decimal number is equal to the comparison value. */ - NumberEquals = 10, - /** != (number) - It matches when the comparison attribute interpreted as a decimal number is not equal to the comparison value. */ - NumberNotEquals = 11, - /** < (number) - It matches when the comparison attribute interpreted as a decimal number is less than the comparison value. */ - NumberLess = 12, - /** <= (number) - It matches when the comparison attribute interpreted as a decimal number is less than or equal to the comparison value. */ - NumberLessOrEquals = 13, - /** > (number) - It matches when the comparison attribute interpreted as a decimal number is greater than the comparison value. */ - NumberGreater = 14, - /** >= (number) - It matches when the comparison attribute interpreted as a decimal number is greater than or equal to the comparison value. */ - NumberGreaterOrEquals = 15, - /** IS ONE OF (hashed) - It matches when the comparison attribute is equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveIsOneOf = 16, - /** IS NOT ONE OF (hashed) - It matches when the comparison attribute is not equal to any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveIsNotOneOf = 17, - /** BEFORE (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is less than the comparison value. */ - DateTimeBefore = 18, - /** AFTER (UTC datetime) - It matches when the comparison attribute interpreted as the seconds elapsed since Unix Epoch is greater than the comparison value. */ - DateTimeAfter = 19, - /** EQUALS (hashed) - It matches when the comparison attribute is equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextEquals = 20, - /** NOT EQUALS (hashed) - It matches when the comparison attribute is not equal to the comparison value (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextNotEquals = 21, - /** STARTS WITH ANY OF (hashed) - It matches when the comparison attribute starts with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextStartsWithAnyOf = 22, - /** NOT STARTS WITH ANY OF (hashed) - It matches when the comparison attribute does not start with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextNotStartsWithAnyOf = 23, - /** ENDS WITH ANY OF (hashed) - It matches when the comparison attribute ends with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextEndsWithAnyOf = 24, - /** NOT ENDS WITH ANY OF (hashed) - It matches when the comparison attribute does not end with any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveTextNotEndsWithAnyOf = 25, - /** ARRAY CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveArrayContainsAnyOf = 26, - /** ARRAY NOT CONTAINS ANY OF (hashed) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values (where the comparison is performed using the salted SHA256 hashes of the values). */ - SensitiveArrayNotContainsAnyOf = 27, - /** EQUALS (cleartext) - It matches when the comparison attribute is equal to the comparison value. */ - TextEquals = 28, - /** NOT EQUALS (cleartext) - It matches when the comparison attribute is not equal to the comparison value. */ - TextNotEquals = 29, - /** STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute starts with any of the comparison values. */ - TextStartsWithAnyOf = 30, - /** NOT STARTS WITH ANY OF (cleartext) - It matches when the comparison attribute does not start with any of the comparison values. */ - TextNotStartsWithAnyOf = 31, - /** ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute ends with any of the comparison values. */ - TextEndsWithAnyOf = 32, - /** NOT ENDS WITH ANY OF (cleartext) - It matches when the comparison attribute does not end with any of the comparison values. */ - TextNotEndsWithAnyOf = 33, - /** ARRAY CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list contains any of the comparison values. */ - ArrayContainsAnyOf = 34, - /** ARRAY NOT CONTAINS ANY OF (cleartext) - It matches when the comparison attribute interpreted as a comma-separated list does not contain any of the comparison values. */ - ArrayNotContainsAnyOf = 35, -} - export type UserConditionComparisonValueTypeMap = { [UserComparator.IsOneOf]: Readonly; [UserComparator.IsNotOneOf]: Readonly; @@ -419,21 +330,13 @@ export class UserCondition readonly comparator: TComparator; readonly comparisonValue: UserConditionComparisonValueTypeMap[TComparator]; - constructor(json: any) { + constructor(json: ConfigJson.UserCondition) { this.comparisonAttribute = json.a; this.comparator = json.c; - this.comparisonValue = json.s ?? json.d ?? json.l; + this.comparisonValue = (json.s ?? json.d ?? json.l) as UserConditionComparisonValueTypeMap[TComparator]; } } -/** Prerequisite flag comparison operator used during the evaluation process. */ -export enum PrerequisiteFlagComparator { - /** EQUALS - It matches when the evaluated value of the specified prerequisite flag is equal to the comparison value. */ - Equals = 0, - /** NOT EQUALS - It matches when the evaluated value of the specified prerequisite flag is not equal to the comparison value. */ - NotEquals = 1 -} - /** Describes a condition that is based on a prerequisite flag. */ export interface IPrerequisiteFlagCondition extends ICondition<"PrerequisiteFlagCondition"> { /** The key of the prerequisite flag that the condition is based on. */ @@ -450,21 +353,13 @@ export class PrerequisiteFlagCondition implements IPrerequisiteFlagCondition { readonly comparator: PrerequisiteFlagComparator; readonly comparisonValue: NonNullable; - constructor(json: any) { + constructor(json: ConfigJson.PrerequisiteFlagCondition) { this.prerequisiteFlagKey = json.f; this.comparator = json.c; this.comparisonValue = unwrapSettingValue(json.v); } } -/** Segment comparison operator used during the evaluation process. */ -export enum SegmentComparator { - /** IS IN SEGMENT - It matches when the conditions of the specified segment are evaluated to true. */ - IsIn, - /** IS NOT IN SEGMENT - It matches when the conditions of the specified segment are evaluated to false. */ - IsNotIn, -} - /** Describes a condition that is based on a segment. */ export interface ISegmentCondition extends ICondition<"SegmentCondition"> { /** The segment that the condition is based on. */ @@ -478,12 +373,12 @@ export class SegmentCondition implements ISegmentCondition { readonly segment: Segment; readonly comparator: SegmentComparator; - constructor(json: any, config: Config) { + constructor(json: ConfigJson.SegmentCondition, config: Config) { this.segment = config.segments[json.s]; this.comparator = json.c; } } -function unwrapSettingValue(json: any): NonNullable { - return json.b ?? json.s ?? json.i ?? json.d; +function unwrapSettingValue(json: ConfigJson.SettingValue): NonNullable { + return (json.b ?? json.s ?? json.i ?? json.d)!; } diff --git a/src/RolloutEvaluator.ts b/src/RolloutEvaluator.ts index a1ba225..4d6c26f 100644 --- a/src/RolloutEvaluator.ts +++ b/src/RolloutEvaluator.ts @@ -1,9 +1,9 @@ import type { LoggerWrapper } from "./ConfigCatLogger"; import { LogLevel } from "./ConfigCatLogger"; +import { PrerequisiteFlagComparator, SegmentComparator, SettingType, UserComparator } from "./ConfigJson"; import { EvaluateLogBuilder, formatSegmentComparator, formatUserCondition, valueToString } from "./EvaluateLogBuilder"; import { sha1, sha256 } from "./Hash"; import type { ConditionUnion, IPercentageOption, ITargetingRule, PercentageOption, PrerequisiteFlagCondition, ProjectConfig, SegmentCondition, Setting, SettingValue, SettingValueContainer, TargetingRule, UserConditionUnion, VariationIdValue } from "./ProjectConfig"; -import { PrerequisiteFlagComparator, SegmentComparator, SettingType, UserComparator } from "./ProjectConfig"; import type { ISemVer } from "./Semver"; import { parse as parseSemVer } from "./Semver"; import type { User, UserAttributeValue } from "./User"; @@ -805,7 +805,7 @@ export type SettingTypeOf = any; /** The evaluated value and additional information about the evaluation of a feature flag or setting. */ -export interface IEvaluationDetails { +export interface IEvaluationDetails { /** Key of the feature flag or setting. */ key: string; @@ -939,7 +939,7 @@ function isCompatibleValue(value: SettingValue, settingType: SettingType): boole } } -function handleInvalidReturnValue(value: unknown): never { +export function handleInvalidReturnValue(value: unknown): never { throw new TypeError( value === null ? "Setting value is null." : value === void 0 ? "Setting value is undefined." : diff --git a/src/index.ts b/src/index.ts index 53ad748..cd4292f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,7 @@ export type { ConditionTypeMap, IConditionUnion, ICondition, UserConditionComparisonValueTypeMap, IUserConditionUnion, IUserCondition, IPrerequisiteFlagCondition, ISegmentCondition } from "./ProjectConfig"; -export { SettingType, UserComparator, PrerequisiteFlagComparator, SegmentComparator } from "./ProjectConfig"; +export { SettingType, UserComparator, PrerequisiteFlagComparator, SegmentComparator } from "./ConfigJson"; export type { IConfigCatClient }; diff --git a/test/ConfigCatCacheTests.ts b/test/ConfigCatCacheTests.ts index 49ea6cb..edafa1f 100644 --- a/test/ConfigCatCacheTests.ts +++ b/test/ConfigCatCacheTests.ts @@ -39,7 +39,7 @@ describe("ConfigCatCache", () => { // 3. When cache is empty, setting a non-empty config with any (even older) timestamp should overwrite the cache. const configJson = "{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}"; - const config3 = new ProjectConfig(configJson, new Config(configJson), config2.timestamp - 1000, "\"ETAG\""); + const config3 = new ProjectConfig(configJson, Config.deserialize(configJson), config2.timestamp - 1000, "\"ETAG\""); await configCache.set(cacheKey, config3); cachedConfig = await configCache.get(cacheKey); @@ -72,7 +72,7 @@ describe("ConfigCatCache", () => { logger.events.length = 0; const configJson = "{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}"; - const config = new ProjectConfig(configJson, new Config(configJson), ProjectConfig.generateTimestamp(), "\"ETAG\""); + const config = new ProjectConfig(configJson, Config.deserialize(configJson), ProjectConfig.generateTimestamp(), "\"ETAG\""); await configCache.set(cacheKey, config); diff --git a/test/ConfigCatClientTests.ts b/test/ConfigCatClientTests.ts index 41161dd..c4d0a18 100644 --- a/test/ConfigCatClientTests.ts +++ b/test/ConfigCatClientTests.ts @@ -186,7 +186,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -227,7 +227,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -268,7 +268,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithRules; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -312,7 +312,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithPercentageOptions; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -355,7 +355,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -408,7 +408,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -458,7 +458,7 @@ describe("ConfigCatClient", () => { const timestamp = new Date().getTime(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; - const cachedPc = new ProjectConfig(configFetcherClass.configJson, new Config(JSON.parse(configFetcherClass.configJson)), timestamp, "etag"); + const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); const configCache = new FakeCache(cachedPc); const configCatKernel: FakeConfigCatKernel = { configFetcher: new configFetcherClass(), sdkType: "common", sdkVersion: "1.0.0" }; const options = new ManualPollOptions("APIKEY", configCatKernel.sdkType, configCatKernel.sdkVersion, {}, () => configCache); @@ -563,8 +563,8 @@ describe("ConfigCatClient", () => { const actualValue = await client.getValueAsync("debug", false); const elapsedMilliseconds: number = new Date().getTime() - startDate; - assert.isAtLeast(elapsedMilliseconds, 500); - assert.isAtMost(elapsedMilliseconds, maxInitWaitTimeSeconds * 1000); + assert.isAtLeast(elapsedMilliseconds, 500 - 10); // 10 ms for tolerance + assert.isAtMost(elapsedMilliseconds, maxInitWaitTimeSeconds * 1000 + 50); // 50 ms for tolerance assert.equal(actualValue, true); }); @@ -585,8 +585,8 @@ describe("ConfigCatClient", () => { const actualDetails = await client.getValueDetailsAsync("debug", false); const elapsedMilliseconds: number = new Date().getTime() - startDate; - assert.isAtLeast(elapsedMilliseconds, 500); - assert.isAtMost(elapsedMilliseconds, configFetchDelay * 2); + assert.isAtLeast(elapsedMilliseconds, 500 - 10); // 10 ms for tolerance + assert.isAtMost(elapsedMilliseconds, configFetchDelay * 2 + 50); // 50 ms for tolerance assert.equal(actualDetails.isDefaultValue, true); assert.equal(actualDetails.value, false); }); @@ -890,7 +890,7 @@ describe("ConfigCatClient", () => { const configFetcher = new FakeConfigFetcher(500); const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": false }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; - const configCache = new FakeCache(new ProjectConfig(configJson, new Config(JSON.parse(configJson)), new Date().getTime() - 10000000, "etag2")); + const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), new Date().getTime() - 10000000, "etag2")); const configCatKernel: FakeConfigCatKernel = { configFetcher, defaultCacheFactory: () => configCache, sdkType: "common", sdkVersion: "1.0.0" }; const options: AutoPollOptions = new AutoPollOptions("APIKEY", "common", "1.0.0", { maxInitWaitTimeSeconds: 10 }); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); @@ -904,7 +904,7 @@ describe("ConfigCatClient", () => { const configFetcher = new FakeConfigFetcher(500); const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": false }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; - const configCache = new FakeCache(new ProjectConfig(configJson, new Config(JSON.parse(configJson)), new Date().getTime() - 10000000, "etag2")); + const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), new Date().getTime() - 10000000, "etag2")); const configCatKernel: FakeConfigCatKernel = { configFetcher, defaultCacheFactory: () => configCache, sdkType: "common", sdkVersion: "1.0.0" }; const options: AutoPollOptions = new AutoPollOptions("APIKEY", "common", "1.0.0", { maxInitWaitTimeSeconds: 10 }); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); @@ -1477,7 +1477,7 @@ describe("ConfigCatClient", () => { if (clientOptions.cache && (initialCacheState === "expired" || initialCacheState === "fresh")) { const timestamp = ProjectConfig.generateTimestamp() - expirationSeconds * 1000 * (initialCacheState === "expired" ? 1.5 : 0.5); - const pc = new ProjectConfig(configJson, new Config(configJson), timestamp, "\"etag\""); + const pc = new ProjectConfig(configJson, Config.deserialize(configJson), timestamp, "\"etag\""); await clientOptions.cache.set(options.getCacheKey(), ProjectConfig.serialize(pc)); } diff --git a/test/ConfigServiceBaseTests.ts b/test/ConfigServiceBaseTests.ts index 6e55f66..59017bb 100644 --- a/test/ConfigServiceBaseTests.ts +++ b/test/ConfigServiceBaseTests.ts @@ -214,7 +214,7 @@ describe("ConfigServiceBaseTests", () => { const pollInterval = 10; const time: number = new Date().getTime(); - const projectConfigOld = createConfigFromFetchResult(frOld).with(time - (pollInterval * 999)); + const projectConfigOld = createConfigFromFetchResult(frOld).with(time - (pollInterval * 1000) + 50); // 50ms for tolerance const cache = new InMemoryConfigCache(); @@ -743,7 +743,7 @@ function createProjectConfig(eTag = "etag"): ProjectConfig { const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": true }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; return new ProjectConfig( configJson, - new Config(JSON.parse(configJson)), + Config.deserialize(configJson), 1, eTag); } diff --git a/test/DataGovernanceTests.ts b/test/DataGovernanceTests.ts index 95427e9..7a887d0 100644 --- a/test/DataGovernanceTests.ts +++ b/test/DataGovernanceTests.ts @@ -2,6 +2,7 @@ import { assert } from "chai"; import "mocha"; import { DataGovernance, OptionsBase } from "../src/ConfigCatClientOptions"; import { FetchResult, IConfigFetcher, IFetchResponse } from "../src/ConfigFetcher"; +import type * as ConfigJson from "../src/ConfigJson"; import { ClientCacheState, ConfigServiceBase } from "../src/ConfigServiceBase"; import { Config, ProjectConfig } from "../src/ProjectConfig"; @@ -279,10 +280,11 @@ export class FakeConfigServiceBase extends ConfigServiceBase { prepareResponse(baseUrl: string, jsonBaseUrl: string, jsonRedirect: number, jsonFeatureFlags: any): void { const configFetcher = this.configFetcher as FakeConfigFetcher; - const configJson = { + const configJson: ConfigJson.Config = { p: { u: jsonBaseUrl, - r: jsonRedirect + r: jsonRedirect, + s: "" }, f: jsonFeatureFlags };