Skip to content

fix(config-cat): Forward default value to underlying client #1214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,9 @@ describe('ConfigCatWebProvider', () => {
expect(() => provider.resolveObjectEvaluation('jsonInvalid', {}, { targetingKey })).toThrow(ParseError);
});

it('should throw TypeMismatchError if string is only a JSON primitive', () => {
expect(() => provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).toThrow(TypeMismatchError);
it('should return right value if key exists and value is only a JSON primitive', () => {
const value = provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey });
expect(value).toHaveProperty('value', JSON.parse(values.jsonPrimitive));
});
});
});
49 changes: 33 additions & 16 deletions libs/providers/config-cat-web/src/lib/config-cat-web-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
EvaluationContext,
FlagNotFoundError,
JsonValue,
OpenFeatureEventEmitter,
Paradigm,
Expand All @@ -13,12 +12,20 @@ import {
} from '@openfeature/web-sdk';
import {
isType,
parseError,
PrimitiveType,
PrimitiveTypeName,
toResolutionDetails,
transformContext,
} from '@openfeature/config-cat-core';
import { getClient, IConfig, IConfigCatClient, OptionsForPollingMode, PollingMode } from 'configcat-js-ssr';
import {
getClient,
IConfig,
IConfigCatClient,
OptionsForPollingMode,
PollingMode,
SettingValue,
} from 'configcat-js-ssr';

export class ConfigCatWebProvider implements Provider {
public readonly events = new OpenFeatureEventEmitter();
Expand Down Expand Up @@ -84,71 +91,81 @@ export class ConfigCatWebProvider implements Provider {
defaultValue: boolean,
context: EvaluationContext,
): ResolutionDetails<boolean> {
return this.evaluate(flagKey, 'boolean', context);
return this.evaluate(flagKey, 'boolean', defaultValue, context);
}

public resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string', context);
return this.evaluate(flagKey, 'string', defaultValue, context);
}

public resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number', context);
return this.evaluate(flagKey, 'number', defaultValue, context);
}

public resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext,
): ResolutionDetails<U> {
const objectValue = this.evaluate(flagKey, 'object', context);
const objectValue = this.evaluate(flagKey, 'object', defaultValue, context);
return objectValue as ResolutionDetails<U>;
}

protected evaluate<T extends PrimitiveTypeName>(
flagKey: string,
flagType: T,
defaultValue: PrimitiveType<T>,
context: EvaluationContext,
): ResolutionDetails<PrimitiveType<T>> {
if (!this._client) {
throw new ProviderNotReadyError('Provider is not initialized');
}

// Make sure that the user-provided `defaultValue` is compatible with `flagType` as there is
// no guarantee that it actually is. (User may bypass type checking or may not use TypeScript at all.)
if (!isType(flagType, defaultValue)) {
throw new TypeMismatchError();
}

const configCatDefaultValue = flagType !== 'object' ? (defaultValue as SettingValue) : JSON.stringify(defaultValue);

const { value, ...evaluationData } = this._client
.snapshot()
.getValueDetails(flagKey, undefined, transformContext(context));
.getValueDetails(flagKey, configCatDefaultValue, transformContext(context));

if (this._hasError && !evaluationData.errorMessage && !evaluationData.errorException) {
this._hasError = false;
this.events.emit(ProviderEvents.Ready);
}

if (typeof value === 'undefined') {
throw new FlagNotFoundError();
if (evaluationData.isDefaultValue) {
throw parseError(evaluationData.errorMessage);
}

if (flagType !== 'object') {
return toResolutionDetails(flagType, value, evaluationData);
}

if (!isType('string', value)) {
throw new TypeMismatchError();
// When `flagType` (more precisely, `configCatDefaultValue`) is boolean, string or number,
// ConfigCat SDK guarantees that the returned `value` is compatible with `PrimitiveType<T>`.
// See also: https://configcat.com/docs/sdk-reference/js-ssr/#setting-type-mapping
return toResolutionDetails(value as PrimitiveType<T>, evaluationData);
}

let json: JsonValue;
try {
json = JSON.parse(value);
// In this case we can be sure that `value` is string since `configCatDefaultValue` is string,
// which means that ConfigCat SDK is guaranteed to return a string value.
json = JSON.parse(value as string);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}

return toResolutionDetails(flagType, json, evaluationData);
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,9 @@ describe('ConfigCatProvider', () => {
await expect(provider.resolveObjectEvaluation('jsonInvalid', {}, { targetingKey })).rejects.toThrow(ParseError);
});

it('should throw TypeMismatchError if string is only a JSON primitive', async () => {
await expect(provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).rejects.toThrow(
TypeMismatchError,
);
it('should return right value if key exists and value is only a JSON primitive', async () => {
const value = await provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey });
expect(value).toHaveProperty('value', JSON.parse(values.jsonPrimitive));
});
});
});
42 changes: 26 additions & 16 deletions libs/providers/config-cat/src/lib/config-cat-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import {
Paradigm,
ProviderNotReadyError,
TypeMismatchError,
FlagNotFoundError,
ParseError,
} from '@openfeature/server-sdk';
import {
isType,
parseError,
PrimitiveType,
PrimitiveTypeName,
toResolutionDetails,
transformContext,
} from '@openfeature/config-cat-core';
import { PollingMode } from 'configcat-common';
import { PollingMode, SettingValue } from 'configcat-common';
import { IConfigCatClient, getClient, IConfig, OptionsForPollingMode } from 'configcat-node';

export class ConfigCatProvider implements Provider {
Expand Down Expand Up @@ -88,46 +88,55 @@ export class ConfigCatProvider implements Provider {
defaultValue: boolean,
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
return this.evaluate(flagKey, 'boolean', context);
return this.evaluate(flagKey, 'boolean', defaultValue, context);
}

public async resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
return this.evaluate(flagKey, 'string', context);
return this.evaluate(flagKey, 'string', defaultValue, context);
}

public async resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
): Promise<ResolutionDetails<number>> {
return this.evaluate(flagKey, 'number', context);
return this.evaluate(flagKey, 'number', defaultValue, context);
}

public async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext,
): Promise<ResolutionDetails<U>> {
const objectValue = await this.evaluate(flagKey, 'object', context);
const objectValue = await this.evaluate(flagKey, 'object', defaultValue, context);
return objectValue as ResolutionDetails<U>;
}

protected async evaluate<T extends PrimitiveTypeName>(
flagKey: string,
flagType: T,
defaultValue: PrimitiveType<T>,
context: EvaluationContext,
): Promise<ResolutionDetails<PrimitiveType<T>>> {
if (!this._client) {
throw new ProviderNotReadyError('Provider is not initialized');
}

// Make sure that the user-provided `defaultValue` is compatible with `flagType` as there is
// no guarantee that it actually is. (User may bypass type checking or may not use TypeScript at all.)
if (!isType(flagType, defaultValue)) {
throw new TypeMismatchError();
}

const configCatDefaultValue = flagType !== 'object' ? (defaultValue as SettingValue) : JSON.stringify(defaultValue);

const { value, ...evaluationData } = await this._client.getValueDetailsAsync(
flagKey,
undefined,
configCatDefaultValue,
transformContext(context),
);

Expand All @@ -136,25 +145,26 @@ export class ConfigCatProvider implements Provider {
this.events.emit(ProviderEvents.Ready);
}

if (typeof value === 'undefined') {
throw new FlagNotFoundError();
if (evaluationData.isDefaultValue) {
throw parseError(evaluationData.errorMessage);
}

if (flagType !== 'object') {
return toResolutionDetails(flagType, value, evaluationData);
}

if (!isType('string', value)) {
throw new TypeMismatchError();
// When `flagType` (more precisely, `configCatDefaultValue`) is boolean, string or number,
// ConfigCat SDK guarantees that the returned `value` is compatible with `PrimitiveType<T>`.
// See also: https://configcat.com/docs/sdk-reference/node/#setting-type-mapping
return toResolutionDetails(value as PrimitiveType<T>, evaluationData);
}

let json: JsonValue;
try {
json = JSON.parse(value);
// In this case we can be sure that `value` is string since `configCatDefaultValue` is string,
// which means that ConfigCat SDK is guaranteed to return a string value.
json = JSON.parse(value as string);
} catch (e) {
throw new ParseError(`Unable to parse "${value}" as JSON`);
}

return toResolutionDetails(flagType, json, evaluationData);
return toResolutionDetails(json as PrimitiveType<T>, evaluationData);
}
}
72 changes: 57 additions & 15 deletions libs/shared/config-cat-core/src/lib/result-mapping.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { IEvaluationDetails } from 'configcat-js-ssr';
import { ResolutionDetails, ResolutionReason, TypeMismatchError, StandardResolutionReasons } from '@openfeature/core';
import {
ResolutionDetails,
ResolutionReason,
TypeMismatchError,
StandardResolutionReasons,
JsonValue,
OpenFeatureError,
ParseError,
TargetingKeyMissingError,
GeneralError,
FlagNotFoundError,
} from '@openfeature/core';

export function toResolutionDetails<T extends PrimitiveTypeName>(
type: T,
value: unknown,
export function toResolutionDetails<T>(
value: T,
data: Omit<IEvaluationDetails, 'value'>,
reason?: ResolutionReason,
): ResolutionDetails<PrimitiveType<T>> {
if (!isType(type, value)) {
throw new TypeMismatchError(`Requested ${type} flag but the actual value is ${typeof value}`);
}

): ResolutionDetails<T> {
const matchedTargeting = data.matchedTargetingRule;
const matchedPercentage = data.matchedPercentageOption;

Expand All @@ -25,19 +31,55 @@ export function toResolutionDetails<T extends PrimitiveTypeName>(
};
}

export type PrimitiveTypeName = 'string' | 'boolean' | 'number' | 'object' | 'undefined';
export function parseError(errorMessage: string | undefined): OpenFeatureError {
// Detecting the error type by checking the error message is awkward and fragile,
// but ConfigCat SDK doesn't allow a better way at the moment.
// However, there are plans to improve this situation, so let's revise this
// as soon as ConfigCat SDK implements returning error codes.

if (errorMessage) {
if (errorMessage.includes('Config JSON is not present')) {
return new ParseError();
}
if (errorMessage.includes('the key was not found in config JSON')) {
return new FlagNotFoundError();
}
if (
errorMessage.includes('The type of a setting must match the type of the specified default value') ||
/Setting value (?:is null|is undefined|'.*' is of an unsupported type)/.test(errorMessage)
) {
return new TypeMismatchError();
}
}
return new GeneralError();
}

export type PrimitiveTypeName = 'string' | 'boolean' | 'number' | 'object';
export type PrimitiveType<T> = T extends 'string'
? string
: T extends 'boolean'
? boolean
: T extends 'number'
? number
: T extends 'object'
? object
: T extends 'undefined'
? undefined
: unknown;
? JsonValue
: never;

export function isType<T extends PrimitiveTypeName>(type: T, value: unknown): value is PrimitiveType<T> {
return typeof value !== 'undefined' && typeof value === type;
switch (type) {
case 'string':
case 'boolean':
case 'number':
return typeof value === type;
case 'object':
return (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
typeof value === 'number' ||
Array.isArray(value) ||
typeof value === 'object'
);
}
return false;
}