chore: address lint issues (#642)

Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
This commit is contained in:
Michael Beemer 2023-11-14 11:06:14 -06:00 committed by GitHub
parent 2213946d9a
commit bbd9aee896
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 829 additions and 877 deletions

View File

@ -1,2 +1,2 @@
export * from './lib/traces'; export * from './lib/traces';
export * from './lib/metrics'; export * from './lib/metrics';

View File

@ -1,16 +1,16 @@
// see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/ // see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/
export const FEATURE_FLAG = 'feature_flag'; export const FEATURE_FLAG = 'feature_flag';
export const EXCEPTION_ATTR = 'exception' export const EXCEPTION_ATTR = 'exception';
export const ACTIVE_COUNT_NAME = `${FEATURE_FLAG}.evaluation_active_count`; export const ACTIVE_COUNT_NAME = `${FEATURE_FLAG}.evaluation_active_count`;
export const REQUESTS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_requests_total`; export const REQUESTS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_requests_total`;
export const SUCCESS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_success_total`; export const SUCCESS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_success_total`;
export const ERROR_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_error_total`; export const ERROR_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_error_total`;
export type EvaluationAttributes = {[key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined }; export type EvaluationAttributes = { [key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined };
export type ExceptionAttributes = { [EXCEPTION_ATTR]: string }; export type ExceptionAttributes = { [EXCEPTION_ATTR]: string };
export const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`; export const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`;
export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`; export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`;
export const VARIANT_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.variant`; export const VARIANT_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.variant`;
export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`; export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`;

View File

@ -1 +1 @@
export * from './metrics-hook'; export * from './metrics-hook';

View File

@ -5,7 +5,7 @@ import {
type EvaluationDetails, type EvaluationDetails,
type FlagValue, type FlagValue,
type Hook, type Hook,
type HookContext type HookContext,
} from '@openfeature/server-sdk'; } from '@openfeature/server-sdk';
import { Attributes, Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api'; import { Attributes, Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api';
import { import {
@ -19,7 +19,7 @@ import {
REASON_ATTR, REASON_ATTR,
REQUESTS_TOTAL_NAME, REQUESTS_TOTAL_NAME,
SUCCESS_TOTAL_NAME, SUCCESS_TOTAL_NAME,
VARIANT_ATTR VARIANT_ATTR,
} from '../conventions'; } from '../conventions';
import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook'; import { OpenTelemetryHook, OpenTelemetryHookOptions } from '../otel-hook';
@ -36,7 +36,7 @@ const ERROR_DESCRIPTION = 'feature flag evaluation error counter';
/** /**
* A hook that adds conventionally-compliant metrics to feature flag evaluations. * A hook that adds conventionally-compliant metrics to feature flag evaluations.
* *
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/} * See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/ */
export class MetricsHook extends OpenTelemetryHook implements Hook { export class MetricsHook extends OpenTelemetryHook implements Hook {
@ -46,7 +46,10 @@ export class MetricsHook extends OpenTelemetryHook implements Hook {
private readonly evaluationSuccessCounter: Counter<EvaluationAttributes | Attributes>; private readonly evaluationSuccessCounter: Counter<EvaluationAttributes | Attributes>;
private readonly evaluationErrorCounter: Counter<ErrorEvaluationAttributes>; private readonly evaluationErrorCounter: Counter<ErrorEvaluationAttributes>;
constructor(options?: MetricsHookOptions, private readonly logger?: Logger) { constructor(
options?: MetricsHookOptions,
private readonly logger?: Logger,
) {
super(options, logger); super(options, logger);
const meter = metrics.getMeter(METER_NAME); const meter = metrics.getMeter(METER_NAME);
this.evaluationActiveUpDownCounter = meter.createUpDownCounter(ACTIVE_COUNT_NAME, { this.evaluationActiveUpDownCounter = meter.createUpDownCounter(ACTIVE_COUNT_NAME, {

View File

@ -1 +1 @@
export * from './tracing-hook'; export * from './tracing-hook';

View File

@ -51,32 +51,32 @@ describe('OpenTelemetry Hooks', () => {
variant: 'enabled', variant: 'enabled',
flagMetadata: {}, flagMetadata: {},
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', { expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey', 'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider', 'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled', 'feature_flag.variant': 'enabled',
}); });
}); });
it('should use a stringified value as the variant value on the span event', () => { it('should use a stringified value as the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = { const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey, flagKey: hookContext.flagKey,
value: true, value: true,
flagMetadata: {}, flagMetadata: {},
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', { expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey', 'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider', 'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'true', 'feature_flag.variant': 'true',
}); });
}); });
it('should set the value without extra quotes if value is already a string', () => { it('should set the value without extra quotes if value is already a string', () => {
const evaluationDetails: EvaluationDetails<string> = { const evaluationDetails: EvaluationDetails<string> = {
flagKey: hookContext.flagKey, flagKey: hookContext.flagKey,
@ -84,14 +84,14 @@ describe('OpenTelemetry Hooks', () => {
flagMetadata: {}, flagMetadata: {},
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', { expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey', 'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider', 'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'already-string', 'feature_flag.variant': 'already-string',
}); });
}); });
it('should not call addEvent because there is no active span', () => { it('should not call addEvent because there is no active span', () => {
getActiveSpan.mockReturnValueOnce(undefined); getActiveSpan.mockReturnValueOnce(undefined);
const evaluationDetails: EvaluationDetails<boolean> = { const evaluationDetails: EvaluationDetails<boolean> = {
@ -100,7 +100,7 @@ describe('OpenTelemetry Hooks', () => {
variant: 'enabled', variant: 'enabled',
flagMetadata: {}, flagMetadata: {},
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).not.toBeCalled(); expect(addEvent).not.toBeCalled();
}); });
@ -108,7 +108,6 @@ describe('OpenTelemetry Hooks', () => {
describe('attribute mapper configured', () => { describe('attribute mapper configured', () => {
describe('no error in mapper', () => { describe('no error in mapper', () => {
beforeEach(() => { beforeEach(() => {
tracingHook = new TracingHook({ tracingHook = new TracingHook({
attributeMapper: (flagMetadata) => { attributeMapper: (flagMetadata) => {
@ -132,9 +131,9 @@ describe('OpenTelemetry Hooks', () => {
metadata3: true, metadata3: true,
}, },
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', { expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey', 'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider', 'feature_flag.provider_name': 'testProvider',
@ -147,7 +146,6 @@ describe('OpenTelemetry Hooks', () => {
}); });
describe('error in mapper', () => { describe('error in mapper', () => {
beforeEach(() => { beforeEach(() => {
tracingHook = new TracingHook({ tracingHook = new TracingHook({
attributeMapper: (_) => { attributeMapper: (_) => {
@ -167,9 +165,9 @@ describe('OpenTelemetry Hooks', () => {
metadata3: true, metadata3: true,
}, },
}; };
tracingHook.after(hookContext, evaluationDetails); tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', { expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey', 'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider', 'feature_flag.provider_name': 'testProvider',

View File

@ -7,7 +7,7 @@ export type TracingHookOptions = OpenTelemetryHookOptions;
/** /**
* A hook that adds conventionally-compliant span events to feature flag evaluations. * A hook that adds conventionally-compliant span events to feature flag evaluations.
* *
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/} * See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/ */
export class TracingHook extends OpenTelemetryHook implements Hook { export class TracingHook extends OpenTelemetryHook implements Hook {

View File

@ -165,7 +165,7 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => { it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveBooleanEvaluation('number1', false, { targetingKey })).rejects.toThrow( await expect(provider.resolveBooleanEvaluation('number1', false, { targetingKey })).rejects.toThrow(
TypeMismatchError TypeMismatchError,
); );
}); });
}); });
@ -183,7 +183,7 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => { it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveStringEvaluation('number1', 'default', { targetingKey })).rejects.toThrow( await expect(provider.resolveStringEvaluation('number1', 'default', { targetingKey })).rejects.toThrow(
TypeMismatchError TypeMismatchError,
); );
}); });
}); });
@ -201,7 +201,7 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if type is different than expected', async () => { it('should throw TypeMismatchError if type is different than expected', async () => {
await expect(provider.resolveNumberEvaluation('stringTest', 0, { targetingKey })).rejects.toThrow( await expect(provider.resolveNumberEvaluation('stringTest', 0, { targetingKey })).rejects.toThrow(
TypeMismatchError TypeMismatchError,
); );
}); });
}); });
@ -223,7 +223,7 @@ describe('ConfigCatProvider', () => {
it('should throw TypeMismatchError if string is only a JSON primitive', async () => { it('should throw TypeMismatchError if string is only a JSON primitive', async () => {
await expect(provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).rejects.toThrow( await expect(provider.resolveObjectEvaluation('jsonPrimitive', {}, { targetingKey })).rejects.toThrow(
TypeMismatchError TypeMismatchError,
); );
}); });
}); });

View File

@ -63,7 +63,7 @@ export class ConfigCatProvider implements Provider {
hooks.on('configChanged', (projectConfig: IConfig | undefined) => hooks.on('configChanged', (projectConfig: IConfig | undefined) =>
this.events.emit(ProviderEvents.ConfigurationChanged, { this.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined, flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
}) }),
); );
hooks.on('clientError', (message: string, error) => { hooks.on('clientError', (message: string, error) => {
@ -90,7 +90,7 @@ export class ConfigCatProvider implements Provider {
async resolveBooleanEvaluation( async resolveBooleanEvaluation(
flagKey: string, flagKey: string,
defaultValue: boolean, defaultValue: boolean,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> { ): Promise<ResolutionDetails<boolean>> {
if (!this._client) { if (!this._client) {
throw new GeneralError('Provider is not initialized'); throw new GeneralError('Provider is not initialized');
@ -99,7 +99,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>( const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey, flagKey,
undefined, undefined,
transformContext(context) transformContext(context),
); );
const validatedValue = validateFlagType('boolean', value); const validatedValue = validateFlagType('boolean', value);
@ -112,7 +112,7 @@ export class ConfigCatProvider implements Provider {
public async resolveStringEvaluation( public async resolveStringEvaluation(
flagKey: string, flagKey: string,
defaultValue: string, defaultValue: string,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<string>> { ): Promise<ResolutionDetails<string>> {
if (!this._client) { if (!this._client) {
throw new GeneralError('Provider is not initialized'); throw new GeneralError('Provider is not initialized');
@ -121,7 +121,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>( const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey, flagKey,
undefined, undefined,
transformContext(context) transformContext(context),
); );
const validatedValue = validateFlagType('string', value); const validatedValue = validateFlagType('string', value);
@ -134,7 +134,7 @@ export class ConfigCatProvider implements Provider {
public async resolveNumberEvaluation( public async resolveNumberEvaluation(
flagKey: string, flagKey: string,
defaultValue: number, defaultValue: number,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<number>> { ): Promise<ResolutionDetails<number>> {
if (!this._client) { if (!this._client) {
throw new GeneralError('Provider is not initialized'); throw new GeneralError('Provider is not initialized');
@ -143,7 +143,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>( const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey, flagKey,
undefined, undefined,
transformContext(context) transformContext(context),
); );
const validatedValue = validateFlagType('number', value); const validatedValue = validateFlagType('number', value);
@ -156,7 +156,7 @@ export class ConfigCatProvider implements Provider {
public async resolveObjectEvaluation<U extends JsonValue>( public async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string, flagKey: string,
defaultValue: U, defaultValue: U,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<U>> { ): Promise<ResolutionDetails<U>> {
if (!this._client) { if (!this._client) {
throw new GeneralError('Provider is not initialized'); throw new GeneralError('Provider is not initialized');
@ -165,7 +165,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync( const { value, ...evaluationData } = await this._client.getValueDetailsAsync(
flagKey, flagKey,
undefined, undefined,
transformContext(context) transformContext(context),
); );
if (typeof value === 'undefined') { if (typeof value === 'undefined') {
@ -197,7 +197,7 @@ export class ConfigCatProvider implements Provider {
function toResolutionDetails<U extends JsonValue>( function toResolutionDetails<U extends JsonValue>(
value: U, value: U,
data: Omit<IEvaluationDetails, 'value'>, data: Omit<IEvaluationDetails, 'value'>,
reason?: ResolutionReason reason?: ResolutionReason,
): ResolutionDetails<U> { ): ResolutionDetails<U> {
const matchedRule = Boolean(data.matchedEvaluationRule || data.matchedEvaluationPercentageRule); const matchedRule = Boolean(data.matchedEvaluationRule || data.matchedEvaluationPercentageRule);
const evaluatedReason = matchedRule ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC; const evaluatedReason = matchedRule ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC;

View File

@ -11,7 +11,6 @@ describe('context-transformer', () => {
expect(() => transformContext(context)).toThrow(TargetingKeyMissingError); expect(() => transformContext(context)).toThrow(TargetingKeyMissingError);
}); });
it('map targeting key to identifier', () => { it('map targeting key to identifier', () => {
const context: EvaluationContext = { const context: EvaluationContext = {
targetingKey: 'test', targetingKey: 'test',
@ -126,8 +125,8 @@ describe('context-transformer', () => {
it('map several custom properties correctly', () => { it('map several custom properties correctly', () => {
const context: EvaluationContext = { const context: EvaluationContext = {
targetingKey: 'test', targetingKey: 'test',
email: "email", email: 'email',
country: "country", country: 'country',
customString: 'customString', customString: 'customString',
customNumber: 1, customNumber: 1,
customBoolean: true, customBoolean: true,
@ -140,8 +139,8 @@ describe('context-transformer', () => {
const user = { const user = {
identifier: 'test', identifier: 'test',
email: "email", email: 'email',
country: "country", country: 'country',
custom: { custom: {
customString: 'customString', customString: 'customString',
customBoolean: 'true', customBoolean: 'true',

View File

@ -75,7 +75,7 @@ export class EnvVarProvider implements Provider {
private evaluateEnvironmentVariable<T extends JsonValue>( private evaluateEnvironmentVariable<T extends JsonValue>(
key: string, key: string,
parse: (value: string) => T parse: (value: string) => T,
): ResolutionDetails<T> { ): ResolutionDetails<T> {
const envVarKey = this.options.disableConstantCase ? key : constantCase(key); const envVarKey = this.options.disableConstantCase ? key : constantCase(key);
const value = process.env[envVarKey]; const value = process.env[envVarKey];

View File

@ -1,7 +1,7 @@
export default { export default {
displayName: 'providers-flagd-web-e2e', displayName: 'providers-flagd-web-e2e',
transform: { transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsConfig: './tsconfig.lib.json'}], '^.+\\.[tj]s$': ['ts-jest', { tsConfig: './tsconfig.lib.json' }],
}, },
testEnvironment: 'node', testEnvironment: 'node',
preset: 'ts-jest', preset: 'ts-jest',
@ -9,4 +9,4 @@ export default {
setupFiles: ['./setup.ts'], setupFiles: ['./setup.ts'],
verbose: true, verbose: true,
silent: false, silent: false,
}; };

View File

@ -6,14 +6,16 @@ const FLAGD_WEB_NAME = 'flagd-web';
// register the flagd provider before the tests. // register the flagd provider before the tests.
console.log('Setting flagd web provider...'); console.log('Setting flagd web provider...');
OpenFeature.setProvider(new FlagdWebProvider({ OpenFeature.setProvider(
host: 'localhost', new FlagdWebProvider({
port: 8013, host: 'localhost',
tls: false, port: 8013,
maxRetries: -1, tls: false,
})); maxRetries: -1,
}),
);
assert( assert(
OpenFeature.providerMetadata.name === FLAGD_WEB_NAME, OpenFeature.providerMetadata.name === FLAGD_WEB_NAME,
new Error(`Expected ${FLAGD_WEB_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`) new Error(`Expected ${FLAGD_WEB_NAME} provider to be configured, instead got: ${OpenFeature.providerMetadata.name}`),
); );
console.log('flagd web provider configured!'); console.log('flagd web provider configured!');

View File

@ -1,4 +1,13 @@
import { EvaluationContext, EvaluationDetails, JsonObject, JsonValue, OpenFeature, ProviderEvents, ResolutionDetails, StandardResolutionReasons } from '@openfeature/web-sdk'; import {
EvaluationContext,
EvaluationDetails,
JsonObject,
JsonValue,
OpenFeature,
ProviderEvents,
ResolutionDetails,
StandardResolutionReasons,
} from '@openfeature/web-sdk';
import { defineFeature, loadFeature } from 'jest-cucumber'; import { defineFeature, loadFeature } from 'jest-cucumber';
// load the feature file. // load the feature file.
@ -8,7 +17,7 @@ const feature = loadFeature('features/evaluation.feature');
const client = OpenFeature.getClient(); const client = OpenFeature.getClient();
const givenAnOpenfeatureClientIsRegistered = ( const givenAnOpenfeatureClientIsRegistered = (
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
) => { ) => {
given('a provider is registered', () => undefined); given('a provider is registered', () => undefined);
}; };
@ -33,7 +42,7 @@ defineFeature(feature, (test) => {
/^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/, /^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
value = client.getBooleanValue(key, defaultValue === 'true'); value = client.getBooleanValue(key, defaultValue === 'true');
} },
); );
then(/^the resolved boolean value should be "(.*)"$/, (expectedValue: string) => { then(/^the resolved boolean value should be "(.*)"$/, (expectedValue: string) => {
@ -50,7 +59,7 @@ defineFeature(feature, (test) => {
/^a string flag with key "(.*)" is evaluated with default value "(.*)"$/, /^a string flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
value = client.getStringValue(key, defaultValue); value = client.getStringValue(key, defaultValue);
} },
); );
then(/^the resolved string value should be "(.*)"$/, (expectedValue: string) => { then(/^the resolved string value should be "(.*)"$/, (expectedValue: string) => {
@ -67,7 +76,7 @@ defineFeature(feature, (test) => {
/^an integer flag with key "(.*)" is evaluated with default value (\d+)$/, /^an integer flag with key "(.*)" is evaluated with default value (\d+)$/,
(key: string, defaultValue: number) => { (key: string, defaultValue: number) => {
value = client.getNumberValue(key, defaultValue); value = client.getNumberValue(key, defaultValue);
} },
); );
then(/^the resolved integer value should be (\d+)$/, (expectedStringValue: string) => { then(/^the resolved integer value should be (\d+)$/, (expectedStringValue: string) => {
@ -85,7 +94,7 @@ defineFeature(feature, (test) => {
/^a float flag with key "(.*)" is evaluated with default value (\d+\.?\d*)$/, /^a float flag with key "(.*)" is evaluated with default value (\d+\.?\d*)$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
value = client.getNumberValue(key, Number.parseFloat(defaultValue)); value = client.getNumberValue(key, Number.parseFloat(defaultValue));
} },
); );
then(/^the resolved float value should be (\d+\.?\d*)$/, (expectedValue: string) => { then(/^the resolved float value should be (\d+\.?\d*)$/, (expectedValue: string) => {
@ -108,7 +117,7 @@ defineFeature(feature, (test) => {
expect(jsonObject[field1]).toEqual(boolVal === 'true'); expect(jsonObject[field1]).toEqual(boolVal === 'true');
expect(jsonObject[field2]).toEqual(strVal); expect(jsonObject[field2]).toEqual(strVal);
expect(jsonObject[field3]).toEqual(Number.parseInt(intVal)); expect(jsonObject[field3]).toEqual(Number.parseInt(intVal));
} },
); );
}); });
@ -120,7 +129,7 @@ defineFeature(feature, (test) => {
/^a boolean flag with key "(.*)" is evaluated with details and default value "(.*)"$/, /^a boolean flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
details = client.getBooleanDetails(key, defaultValue === 'true'); details = client.getBooleanDetails(key, defaultValue === 'true');
} },
); );
then( then(
@ -129,7 +138,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(expectedValue === 'true'); expect(details.value).toEqual(expectedValue === 'true');
expect(details.variant).toEqual(expectedVariant); expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason); expect(details.reason).toEqual(expectedReason);
} },
); );
}); });
@ -142,7 +151,7 @@ defineFeature(feature, (test) => {
/^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/, /^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
details = client.getStringDetails(key, defaultValue); details = client.getStringDetails(key, defaultValue);
} },
); );
then( then(
@ -151,7 +160,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(expectedValue); expect(details.value).toEqual(expectedValue);
expect(details.variant).toEqual(expectedVariant); expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason); expect(details.reason).toEqual(expectedReason);
} },
); );
}); });
@ -164,7 +173,7 @@ defineFeature(feature, (test) => {
/^an integer flag with key "(.*)" is evaluated with details and default value (\d+)$/, /^an integer flag with key "(.*)" is evaluated with details and default value (\d+)$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
details = client.getNumberDetails(key, Number.parseInt(defaultValue)); details = client.getNumberDetails(key, Number.parseInt(defaultValue));
} },
); );
then( then(
@ -173,7 +182,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(Number.parseInt(expectedValue)); expect(details.value).toEqual(Number.parseInt(expectedValue));
expect(details.variant).toEqual(expectedVariant); expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason); expect(details.reason).toEqual(expectedReason);
} },
); );
}); });
@ -186,7 +195,7 @@ defineFeature(feature, (test) => {
/^a float flag with key "(.*)" is evaluated with details and default value (\d+\.?\d*)$/, /^a float flag with key "(.*)" is evaluated with details and default value (\d+\.?\d*)$/,
(key: string, defaultValue: string) => { (key: string, defaultValue: string) => {
details = client.getNumberDetails(key, Number.parseFloat(defaultValue)); details = client.getNumberDetails(key, Number.parseFloat(defaultValue));
} },
); );
then( then(
@ -195,7 +204,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(Number.parseFloat(expectedValue)); expect(details.value).toEqual(Number.parseFloat(expectedValue));
expect(details.variant).toEqual(expectedVariant); expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason); expect(details.reason).toEqual(expectedReason);
} },
); );
}); });
@ -216,7 +225,7 @@ defineFeature(feature, (test) => {
expect(jsonObject[field1]).toEqual(boolValue === 'true'); expect(jsonObject[field1]).toEqual(boolValue === 'true');
expect(jsonObject[field2]).toEqual(stringValue); expect(jsonObject[field2]).toEqual(stringValue);
expect(jsonObject[field3]).toEqual(Number.parseInt(intValue)); expect(jsonObject[field3]).toEqual(Number.parseInt(intValue));
} },
); );
and( and(
@ -224,7 +233,7 @@ defineFeature(feature, (test) => {
(expectedVariant: string, expectedReason: string) => { (expectedVariant: string, expectedReason: string) => {
expect(details.variant).toEqual(expectedVariant); expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason); expect(details.reason).toEqual(expectedReason);
} },
); );
}); });
@ -245,7 +254,7 @@ defineFeature(feature, (test) => {
stringValue1: string, stringValue1: string,
stringValue2: string, stringValue2: string,
intValue: string, intValue: string,
boolValue: string boolValue: string,
) => { ) => {
context[stringField1] = stringValue1; context[stringField1] = stringValue1;
context[stringField2] = stringValue2; context[stringField2] = stringValue2;
@ -253,7 +262,7 @@ defineFeature(feature, (test) => {
context[boolField] = boolValue === 'true'; context[boolField] = boolValue === 'true';
await OpenFeature.setContext(context); await OpenFeature.setContext(context);
} },
); );
and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, (key: string, defaultValue: string) => { and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, (key: string, defaultValue: string) => {
@ -285,7 +294,7 @@ defineFeature(feature, (test) => {
flagKey = key; flagKey = key;
fallbackValue = defaultValue; fallbackValue = defaultValue;
details = client.getStringDetails(flagKey, defaultValue); details = client.getStringDetails(flagKey, defaultValue);
} },
); );
then(/^the default string value should be returned$/, () => { then(/^the default string value should be returned$/, () => {
@ -297,7 +306,7 @@ defineFeature(feature, (test) => {
(errorCode: string) => { (errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR); expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode); expect(details.errorCode).toEqual(errorCode);
} },
); );
}); });
@ -314,7 +323,7 @@ defineFeature(feature, (test) => {
flagKey = key; flagKey = key;
fallbackValue = Number.parseInt(defaultValue); fallbackValue = Number.parseInt(defaultValue);
details = client.getNumberDetails(flagKey, Number.parseInt(defaultValue)); details = client.getNumberDetails(flagKey, Number.parseInt(defaultValue));
} },
); );
then(/^the default integer value should be returned$/, () => { then(/^the default integer value should be returned$/, () => {
@ -326,7 +335,7 @@ defineFeature(feature, (test) => {
(errorCode: string) => { (errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR); expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode); expect(details.errorCode).toEqual(errorCode);
} },
); );
}); });
}); });

View File

@ -50,7 +50,7 @@ export class FlagdWebProvider implements Provider {
options: FlagdProviderOptions, options: FlagdProviderOptions,
logger?: Logger, logger?: Logger,
promiseClient?: PromiseClient<typeof Service>, promiseClient?: PromiseClient<typeof Service>,
callbackClient?: CallbackClient<typeof Service> callbackClient?: CallbackClient<typeof Service>,
) { ) {
const { host, port, tls, maxRetries, maxDelay, pathPrefix } = getOptions(options); const { host, port, tls, maxRetries, maxDelay, pathPrefix } = getOptions(options);
const transport = createConnectTransport({ const transport = createConnectTransport({
@ -151,7 +151,7 @@ export class FlagdWebProvider implements Provider {
this._logger?.warn(`${FlagdWebProvider.name}: max retries reached`); this._logger?.warn(`${FlagdWebProvider.name}: max retries reached`);
this.events.emit(ProviderEvents.Error); this.events.emit(ProviderEvents.Error);
} }
} },
); );
}); });
} }

View File

@ -13,7 +13,7 @@ export interface Options {
/** /**
* The path at which the flagd gRPC service is available, for example: /flagd-api (optional). * The path at which the flagd gRPC service is available, for example: /flagd-api (optional).
* *
* @default "" * @default ""
*/ */
pathPrefix: string; pathPrefix: string;
@ -52,7 +52,7 @@ export function getOptions(options: FlagdProviderOptions): Options {
tls: true, tls: true,
maxRetries: 0, maxRetries: 0,
maxDelay: DEFAULT_MAX_DELAY, maxDelay: DEFAULT_MAX_DELAY,
pathPrefix: "" pathPrefix: '',
}, },
...options, ...options,
}; };

View File

@ -1,6 +1,6 @@
import {GoFeatureFlagEvaluationContext} from './model'; import { GoFeatureFlagEvaluationContext } from './model';
import {transformContext} from './context-transformer'; import { transformContext } from './context-transformer';
import {TargetingKeyMissingError, EvaluationContext} from "@openfeature/web-sdk"; import { TargetingKeyMissingError, EvaluationContext } from '@openfeature/web-sdk';
describe('contextTransformer', () => { describe('contextTransformer', () => {
it('should use the targetingKey as user key', () => { it('should use the targetingKey as user key', () => {

View File

@ -1,15 +1,13 @@
import {GoFeatureFlagEvaluationContext} from './model'; import { GoFeatureFlagEvaluationContext } from './model';
import {TargetingKeyMissingError, EvaluationContext} from "@openfeature/web-sdk"; import { TargetingKeyMissingError, EvaluationContext } from '@openfeature/web-sdk';
/** /**
* transformContext takes the raw OpenFeature context returns a GoFeatureFlagEvaluationContext. * transformContext takes the raw OpenFeature context returns a GoFeatureFlagEvaluationContext.
* @param context - the context used for flag evaluation. * @param context - the context used for flag evaluation.
* @returns {GoFeatureFlagEvaluationContext} the user against who we will evaluate the flag. * @returns {GoFeatureFlagEvaluationContext} the user against who we will evaluate the flag.
*/ */
export function transformContext( export function transformContext(context: EvaluationContext): GoFeatureFlagEvaluationContext {
context: EvaluationContext const { targetingKey, ...attributes } = context;
): GoFeatureFlagEvaluationContext {
const {targetingKey, ...attributes} = context;
if (targetingKey === undefined || targetingKey === null || targetingKey === '') { if (targetingKey === undefined || targetingKey === null || targetingKey === '') {
throw new TargetingKeyMissingError(); throw new TargetingKeyMissingError();
} }

View File

@ -3,9 +3,9 @@
* the method fetch. * the method fetch.
* It allows to throw an error with the status code. * It allows to throw an error with the status code.
*/ */
export class FetchError extends Error{ export class FetchError extends Error {
status: number; status: number;
constructor(status:number) { constructor(status: number) {
super(`Request failed with status code ${status}`); super(`Request failed with status code ${status}`);
this.status = status; this.status = status;
} }

View File

@ -1,4 +1,4 @@
import {GoFeatureFlagWebProvider} from './go-feature-flag-web-provider'; import { GoFeatureFlagWebProvider } from './go-feature-flag-web-provider';
import { import {
EvaluationContext, EvaluationContext,
OpenFeature, OpenFeature,
@ -6,12 +6,12 @@ import {
StandardResolutionReasons, StandardResolutionReasons,
ErrorCode, ErrorCode,
EvaluationDetails, EvaluationDetails,
JsonValue} JsonValue,
from "@openfeature/web-sdk"; } from '@openfeature/web-sdk';
import WS from "jest-websocket-mock"; import WS from 'jest-websocket-mock';
import TestLogger from "./test-logger"; import TestLogger from './test-logger';
import {GOFeatureFlagWebsocketResponse} from "./model"; import { GOFeatureFlagWebsocketResponse } from './model';
import fetchMock from "fetch-mock-jest"; import fetchMock from 'fetch-mock-jest';
describe('GoFeatureFlagWebProvider', () => { describe('GoFeatureFlagWebProvider', () => {
let websocketMockServer: WS; let websocketMockServer: WS;
@ -19,65 +19,65 @@ describe('GoFeatureFlagWebProvider', () => {
const allFlagsEndpoint = `${endpoint}v1/allflags`; const allFlagsEndpoint = `${endpoint}v1/allflags`;
const websocketEndpoint = 'ws://localhost:1031/ws/v1/flag/change'; const websocketEndpoint = 'ws://localhost:1031/ws/v1/flag/change';
const defaultAllFlagResponse = { const defaultAllFlagResponse = {
"flags": { flags: {
"bool_flag": { bool_flag: {
"value": true, value: true,
"timestamp": 1689020159, timestamp: 1689020159,
"variationType": "True", variationType: 'True',
"trackEvents": true, trackEvents: true,
"reason": "DEFAULT", reason: 'DEFAULT',
"metadata": { metadata: {
"description": "this is a test flag" description: 'this is a test flag',
} },
}, },
"number_flag": { number_flag: {
"value": 123, value: 123,
"timestamp": 1689020159, timestamp: 1689020159,
"variationType": "True", variationType: 'True',
"trackEvents": true, trackEvents: true,
"reason": "DEFAULT", reason: 'DEFAULT',
"metadata": { metadata: {
"description": "this is a test flag" description: 'this is a test flag',
} },
}, },
"string_flag": { string_flag: {
"value": 'value-flag', value: 'value-flag',
"timestamp": 1689020159, timestamp: 1689020159,
"variationType": "True", variationType: 'True',
"trackEvents": true, trackEvents: true,
"reason": "DEFAULT", reason: 'DEFAULT',
"metadata": { metadata: {
"description": "this is a test flag" description: 'this is a test flag',
} },
},
object_flag: {
value: { id: '123' },
timestamp: 1689020159,
variationType: 'True',
trackEvents: true,
reason: 'DEFAULT',
metadata: {
description: 'this is a test flag',
},
}, },
"object_flag": {
"value": {id: '123'},
"timestamp": 1689020159,
"variationType": "True",
"trackEvents": true,
"reason": "DEFAULT",
"metadata": {
"description": "this is a test flag"
}
}
}, },
"valid": true valid: true,
}; };
const alternativeAllFlagResponse = { const alternativeAllFlagResponse = {
"flags": { flags: {
"bool_flag": { bool_flag: {
"value": false, value: false,
"timestamp": 1689020159, timestamp: 1689020159,
"variationType": "NEW_VARIATION", variationType: 'NEW_VARIATION',
"trackEvents": false, trackEvents: false,
"errorCode": "", errorCode: '',
"reason": "TARGETING_MATCH", reason: 'TARGETING_MATCH',
"metadata": { metadata: {
"description": "this is a test flag" description: 'this is a test flag',
} },
} },
}, },
"valid": true valid: true,
}; };
let defaultProvider: GoFeatureFlagWebProvider; let defaultProvider: GoFeatureFlagWebProvider;
let defaultContext: EvaluationContext; let defaultContext: EvaluationContext;
@ -93,19 +93,22 @@ describe('GoFeatureFlagWebProvider', () => {
fetchMock.mockClear(); fetchMock.mockClear();
fetchMock.mockReset(); fetchMock.mockReset();
await jest.resetAllMocks(); await jest.resetAllMocks();
websocketMockServer = new WS(websocketEndpoint, {jsonProtocol: true}); websocketMockServer = new WS(websocketEndpoint, { jsonProtocol: true });
fetchMock.post(allFlagsEndpoint,defaultAllFlagResponse); fetchMock.post(allFlagsEndpoint, defaultAllFlagResponse);
defaultProvider = new GoFeatureFlagWebProvider({ defaultProvider = new GoFeatureFlagWebProvider(
endpoint: endpoint, {
apiTimeout: 1000, endpoint: endpoint,
maxRetries: 1, apiTimeout: 1000,
}, logger); maxRetries: 1,
defaultContext = {targetingKey: 'user-key'}; },
logger,
);
defaultContext = { targetingKey: 'user-key' };
}); });
afterEach(async () => { afterEach(async () => {
await WS.clean(); await WS.clean();
websocketMockServer.close() websocketMockServer.close();
await OpenFeature.close(); await OpenFeature.close();
await OpenFeature.clearHooks(); await OpenFeature.clearHooks();
fetchMock.mockClear(); fetchMock.mockClear();
@ -120,11 +123,14 @@ describe('GoFeatureFlagWebProvider', () => {
}); });
function newDefaultProvider(): GoFeatureFlagWebProvider { function newDefaultProvider(): GoFeatureFlagWebProvider {
return new GoFeatureFlagWebProvider({ return new GoFeatureFlagWebProvider(
endpoint: endpoint, {
apiTimeout: 1000, endpoint: endpoint,
maxRetries: 1, apiTimeout: 1000,
}, logger); maxRetries: 1,
},
logger,
);
} }
describe('provider metadata', () => { describe('provider metadata', () => {
@ -146,8 +152,8 @@ describe('GoFeatureFlagWebProvider', () => {
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
const got1 = client.getBooleanDetails('bool_flag', false); const got1 = client.getBooleanDetails('bool_flag', false);
fetchMock.post(allFlagsEndpoint, alternativeAllFlagResponse, {overwriteRoutes: true}); fetchMock.post(allFlagsEndpoint, alternativeAllFlagResponse, { overwriteRoutes: true });
await OpenFeature.setContext({targetingKey: "1234"}); await OpenFeature.setContext({ targetingKey: '1234' });
const got2 = client.getBooleanDetails('bool_flag', false); const got2 = client.getBooleanDetails('bool_flag', false);
expect(got1.value).toEqual(defaultAllFlagResponse.flags.bool_flag.value); expect(got1.value).toEqual(defaultAllFlagResponse.flags.bool_flag.value);
@ -167,14 +173,14 @@ describe('GoFeatureFlagWebProvider', () => {
await websocketMockServer.connected; await websocketMockServer.connected;
// Need to wait before using the mock // Need to wait before using the mock
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
await websocketMockServer.close() await websocketMockServer.close();
const got = client.getBooleanDetails('bool_flag', false); const got = client.getBooleanDetails('bool_flag', false);
expect(got.reason).toEqual(StandardResolutionReasons.CACHED); expect(got.reason).toEqual(StandardResolutionReasons.CACHED);
}); });
it('should emit an error if we have the wrong credentials', async () => { it('should emit an error if we have the wrong credentials', async () => {
fetchMock.post(allFlagsEndpoint,401, {overwriteRoutes: true}); fetchMock.post(allFlagsEndpoint, 401, { overwriteRoutes: true });
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider(providerName, newDefaultProvider()); OpenFeature.setProvider(providerName, newDefaultProvider());
@ -182,13 +188,14 @@ describe('GoFeatureFlagWebProvider', () => {
client.addHandler(ProviderEvents.Error, errorHandler); client.addHandler(ProviderEvents.Error, errorHandler);
// wait the event to be triggered // wait the event to be triggered
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
expect(errorHandler).toBeCalled() expect(errorHandler).toBeCalled();
expect(logger.inMemoryLogger['error'][0]) expect(logger.inMemoryLogger['error'][0]).toEqual(
.toEqual('GoFeatureFlagWebProvider: invalid token used to contact GO Feature Flag instance: Error: Request failed with status code 401'); 'GoFeatureFlagWebProvider: invalid token used to contact GO Feature Flag instance: Error: Request failed with status code 401',
);
}); });
it('should emit an error if we receive a 404 from GO Feature Flag', async () => { it('should emit an error if we receive a 404 from GO Feature Flag', async () => {
fetchMock.post(allFlagsEndpoint,404, {overwriteRoutes: true}); fetchMock.post(allFlagsEndpoint, 404, { overwriteRoutes: true });
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
@ -198,9 +205,10 @@ describe('GoFeatureFlagWebProvider', () => {
client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler); client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler);
// wait the event to be triggered // wait the event to be triggered
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
expect(errorHandler).toBeCalled() expect(errorHandler).toBeCalled();
expect(logger.inMemoryLogger['error'][0]) expect(logger.inMemoryLogger['error'][0]).toEqual(
.toEqual('GoFeatureFlagWebProvider: impossible to call go-feature-flag relay proxy Error: Request failed with status code 404'); 'GoFeatureFlagWebProvider: impossible to call go-feature-flag relay proxy Error: Request failed with status code 404',
);
}); });
it('should get a valid boolean flag evaluation', async () => { it('should get a valid boolean flag evaluation', async () => {
@ -208,14 +216,14 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getBooleanDetails(flagKey, false); const got = client.getBooleanDetails(flagKey, false);
const want: EvaluationDetails<boolean> = { const want: EvaluationDetails<boolean> = {
flagKey, flagKey,
value: true, value: true,
variant: 'True', variant: 'True',
flagMetadata: { flagMetadata: {
description: "this is a test flag" description: 'this is a test flag',
}, },
reason: StandardResolutionReasons.DEFAULT, reason: StandardResolutionReasons.DEFAULT,
}; };
@ -227,14 +235,14 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getStringDetails(flagKey, 'false'); const got = client.getStringDetails(flagKey, 'false');
const want: EvaluationDetails<string> = { const want: EvaluationDetails<string> = {
flagKey, flagKey,
value: 'value-flag', value: 'value-flag',
variant: 'True', variant: 'True',
flagMetadata: { flagMetadata: {
description: "this is a test flag" description: 'this is a test flag',
}, },
reason: StandardResolutionReasons.DEFAULT, reason: StandardResolutionReasons.DEFAULT,
}; };
@ -246,14 +254,14 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getNumberDetails(flagKey, 456); const got = client.getNumberDetails(flagKey, 456);
const want: EvaluationDetails<number> = { const want: EvaluationDetails<number> = {
flagKey, flagKey,
value: 123, value: 123,
variant: 'True', variant: 'True',
flagMetadata: { flagMetadata: {
description: "this is a test flag" description: 'this is a test flag',
}, },
reason: StandardResolutionReasons.DEFAULT, reason: StandardResolutionReasons.DEFAULT,
}; };
@ -265,14 +273,14 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getObjectDetails(flagKey, {error: true}); const got = client.getObjectDetails(flagKey, { error: true });
const want: EvaluationDetails<JsonValue> = { const want: EvaluationDetails<JsonValue> = {
flagKey, flagKey,
value: {id: "123"}, value: { id: '123' },
variant: 'True', variant: 'True',
flagMetadata: { flagMetadata: {
description: "this is a test flag" description: 'this is a test flag',
}, },
reason: StandardResolutionReasons.DEFAULT, reason: StandardResolutionReasons.DEFAULT,
}; };
@ -284,15 +292,15 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getStringDetails(flagKey, 'false'); const got = client.getStringDetails(flagKey, 'false');
const want: EvaluationDetails<string> = { const want: EvaluationDetails<string> = {
flagKey, flagKey,
value: "false", value: 'false',
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.TYPE_MISMATCH, errorCode: ErrorCode.TYPE_MISMATCH,
flagMetadata: {}, flagMetadata: {},
errorMessage: "flag key bool_flag is not of type string", errorMessage: 'flag key bool_flag is not of type string',
}; };
expect(got).toEqual(want); expect(got).toEqual(want);
}); });
@ -302,7 +310,7 @@ describe('GoFeatureFlagWebProvider', () => {
await OpenFeature.setContext(defaultContext); await OpenFeature.setContext(defaultContext);
OpenFeature.setProvider('test-provider', defaultProvider); OpenFeature.setProvider('test-provider', defaultProvider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
await websocketMockServer.connected await websocketMockServer.connected;
const got = client.getBooleanDetails(flagKey, false); const got = client.getBooleanDetails(flagKey, false);
const want: EvaluationDetails<boolean> = { const want: EvaluationDetails<boolean> = {
flagKey, flagKey,
@ -310,7 +318,7 @@ describe('GoFeatureFlagWebProvider', () => {
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.FLAG_NOT_FOUND, errorCode: ErrorCode.FLAG_NOT_FOUND,
flagMetadata: {}, flagMetadata: {},
errorMessage: "flag key not-exist not found in cache", errorMessage: 'flag key not-exist not found in cache',
}; };
expect(got).toEqual(want); expect(got).toEqual(want);
}); });
@ -353,17 +361,17 @@ describe('GoFeatureFlagWebProvider', () => {
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
websocketMockServer.send({ websocketMockServer.send({
added: { added: {
"added-flag-1": {}, 'added-flag-1': {},
"added-flag-2": {} 'added-flag-2': {},
}, },
updated: { updated: {
"updated-flag-1": {}, 'updated-flag-1': {},
"updated-flag-2": {}, 'updated-flag-2': {},
}, },
deleted: { deleted: {
"deleted-flag-1": {}, 'deleted-flag-1': {},
"deleted-flag-2": {}, 'deleted-flag-2': {},
} },
} as GOFeatureFlagWebsocketResponse); } as GOFeatureFlagWebsocketResponse);
// waiting the call to the API to be successful // waiting the call to the API to be successful
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@ -375,17 +383,27 @@ describe('GoFeatureFlagWebProvider', () => {
expect(configurationChangedHandler.mock.calls[0][0]).toEqual({ expect(configurationChangedHandler.mock.calls[0][0]).toEqual({
clientName: 'test-provider', clientName: 'test-provider',
message: 'flag configuration have changed', message: 'flag configuration have changed',
flagsChanged: ['deleted-flag-1', 'deleted-flag-2', 'updated-flag-1', 'updated-flag-2', 'added-flag-1', 'added-flag-2'] flagsChanged: [
}) 'deleted-flag-1',
'deleted-flag-2',
'updated-flag-1',
'updated-flag-2',
'added-flag-1',
'added-flag-2',
],
});
}); });
it('should call client handler with ProviderEvents.Stale when websocket is unreachable', async () => { it('should call client handler with ProviderEvents.Stale when websocket is unreachable', async () => {
// await OpenFeature.setContext(defaultContext); // we deactivate this call because the context is already set, and we want to avoid calling contextChanged function // await OpenFeature.setContext(defaultContext); // we deactivate this call because the context is already set, and we want to avoid calling contextChanged function
const provider = new GoFeatureFlagWebProvider({ const provider = new GoFeatureFlagWebProvider(
endpoint, {
maxRetries: 1, endpoint,
retryInitialDelay: 10, maxRetries: 1,
}, logger); retryInitialDelay: 10,
},
logger,
);
OpenFeature.setProvider('test-provider', provider); OpenFeature.setProvider('test-provider', provider);
const client = await OpenFeature.getClient('test-provider'); const client = await OpenFeature.getClient('test-provider');
client.addHandler(ProviderEvents.Ready, readyHandler); client.addHandler(ProviderEvents.Ready, readyHandler);
@ -398,7 +416,7 @@ describe('GoFeatureFlagWebProvider', () => {
// Need to wait before using the mock // Need to wait before using the mock
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
await websocketMockServer.close() await websocketMockServer.close();
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
expect(readyHandler).toBeCalled(); expect(readyHandler).toBeCalled();
@ -406,5 +424,5 @@ describe('GoFeatureFlagWebProvider', () => {
expect(configurationChangedHandler).not.toBeCalled(); expect(configurationChangedHandler).not.toBeCalled();
expect(staleHandler).toBeCalled(); expect(staleHandler).toBeCalled();
}); });
}) });
}); });

View File

@ -11,19 +11,19 @@ import {
ResolutionDetails, ResolutionDetails,
StandardResolutionReasons, StandardResolutionReasons,
TypeMismatchError, TypeMismatchError,
} from "@openfeature/web-sdk"; } from '@openfeature/web-sdk';
import { import {
FlagState, FlagState,
GoFeatureFlagAllFlagRequest, GoFeatureFlagAllFlagRequest,
GOFeatureFlagAllFlagsResponse, GOFeatureFlagAllFlagsResponse,
GoFeatureFlagWebProviderOptions, GoFeatureFlagWebProviderOptions,
GOFeatureFlagWebsocketResponse, GOFeatureFlagWebsocketResponse,
} from "./model"; } from './model';
import {transformContext} from "./context-transformer"; import { transformContext } from './context-transformer';
import {FetchError} from "./fetch-error"; import { FetchError } from './fetch-error';
export class GoFeatureFlagWebProvider implements Provider { export class GoFeatureFlagWebProvider implements Provider {
private readonly _websocketPath = "ws/v1/flag/change" private readonly _websocketPath = 'ws/v1/flag/change';
metadata = { metadata = {
name: GoFeatureFlagWebProvider.name, name: GoFeatureFlagWebProvider.name,
@ -73,7 +73,9 @@ export class GoFeatureFlagWebProvider implements Provider {
this._logger?.debug(`${GoFeatureFlagWebProvider.name}: go-feature-flag provider initialized`); this._logger?.debug(`${GoFeatureFlagWebProvider.name}: go-feature-flag provider initialized`);
}) })
.catch((error) => { .catch((error) => {
this._logger?.error(`${GoFeatureFlagWebProvider.name}: initialization failed, provider is on error, we will try to reconnect: ${error}`); this._logger?.error(
`${GoFeatureFlagWebProvider.name}: initialization failed, provider is on error, we will try to reconnect: ${error}`,
);
this._status = ProviderStatus.ERROR; this._status = ProviderStatus.ERROR;
this.handleFetchErrors(error); this.handleFetchErrors(error);
@ -81,7 +83,7 @@ export class GoFeatureFlagWebProvider implements Provider {
// and we launch the retry to fetch the data. // and we launch the retry to fetch the data.
this.retryFetchAll(context); this.retryFetchAll(context);
this.reconnectWebsocket(); this.reconnectWebsocket();
}) });
} }
/** /**
@ -90,16 +92,17 @@ export class GoFeatureFlagWebProvider implements Provider {
*/ */
async connectWebsocket(): Promise<void> { async connectWebsocket(): Promise<void> {
const wsURL = new URL(this._endpoint); const wsURL = new URL(this._endpoint);
wsURL.pathname = wsURL.pathname = wsURL.pathname.endsWith('/')
wsURL.pathname.endsWith('/') ? wsURL.pathname + this._websocketPath : wsURL.pathname + '/' + this._websocketPath; ? wsURL.pathname + this._websocketPath
: wsURL.pathname + '/' + this._websocketPath;
wsURL.protocol = wsURL.protocol === 'https:' ? 'wss' : 'ws'; wsURL.protocol = wsURL.protocol === 'https:' ? 'wss' : 'ws';
// adding API Key if GO Feature Flag use api keys. // adding API Key if GO Feature Flag use api keys.
if(this._apiKey){ if (this._apiKey) {
wsURL.searchParams.set('apiKey', this._apiKey); wsURL.searchParams.set('apiKey', this._apiKey);
} }
this._logger?.debug(`${GoFeatureFlagWebProvider.name}: Trying to connect the websocket at ${wsURL}`) this._logger?.debug(`${GoFeatureFlagWebProvider.name}: Trying to connect the websocket at ${wsURL}`);
this._websocket = new WebSocket(wsURL); this._websocket = new WebSocket(wsURL);
await this.waitWebsocketFinalStatus(this._websocket); await this.waitWebsocketFinalStatus(this._websocket);
@ -107,12 +110,12 @@ export class GoFeatureFlagWebProvider implements Provider {
this._websocket.onopen = (event) => { this._websocket.onopen = (event) => {
this._logger?.info(`${GoFeatureFlagWebProvider.name}: Websocket to go-feature-flag open: ${event}`); this._logger?.info(`${GoFeatureFlagWebProvider.name}: Websocket to go-feature-flag open: ${event}`);
}; };
this._websocket.onmessage = async ({data}) => { this._websocket.onmessage = async ({ data }) => {
this._logger?.info(`${GoFeatureFlagWebProvider.name}: Change in your configuration flag`); this._logger?.info(`${GoFeatureFlagWebProvider.name}: Change in your configuration flag`);
const t: GOFeatureFlagWebsocketResponse = JSON.parse(data); const t: GOFeatureFlagWebsocketResponse = JSON.parse(data);
const flagsChanged = this.extractFlagNamesFromWebsocket(t); const flagsChanged = this.extractFlagNamesFromWebsocket(t);
await this.retryFetchAll(OpenFeature.getContext(), flagsChanged); await this.retryFetchAll(OpenFeature.getContext(), flagsChanged);
} };
this._websocket.onclose = async () => { this._websocket.onclose = async () => {
this._logger?.warn(`${GoFeatureFlagWebProvider.name}: Websocket closed, trying to reconnect`); this._logger?.warn(`${GoFeatureFlagWebProvider.name}: Websocket closed, trying to reconnect`);
await this.reconnectWebsocket(); await this.reconnectWebsocket();
@ -167,46 +170,48 @@ export class GoFeatureFlagWebProvider implements Provider {
let attempt = 0; let attempt = 0;
while (attempt < this._maxRetries) { while (attempt < this._maxRetries) {
attempt++; attempt++;
await this.connectWebsocket() await this.connectWebsocket();
if (this._websocket !== undefined && this._websocket.readyState === WebSocket.OPEN) { if (this._websocket !== undefined && this._websocket.readyState === WebSocket.OPEN) {
return return;
} }
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
delay *= this._retryDelayMultiplier; delay *= this._retryDelayMultiplier;
this._logger?.info(`${GoFeatureFlagWebProvider.name}: error while reconnecting the websocket, next try in ${delay} ms (${attempt}/${this._maxRetries}).`) this._logger?.info(
`${GoFeatureFlagWebProvider.name}: error while reconnecting the websocket, next try in ${delay} ms (${attempt}/${this._maxRetries}).`,
);
} }
this.events.emit(ProviderEvents.Stale, { this.events.emit(ProviderEvents.Stale, {
message: 'impossible to get status from GO Feature Flag (websocket connection stopped)' message: 'impossible to get status from GO Feature Flag (websocket connection stopped)',
}); });
} }
onClose(): Promise<void> { onClose(): Promise<void> {
this._websocket?.close(1000, "Closing GO Feature Flag provider"); this._websocket?.close(1000, 'Closing GO Feature Flag provider');
return Promise.resolve(); return Promise.resolve();
} }
async onContextChange(_: EvaluationContext, newContext: EvaluationContext): Promise<void> { async onContextChange(_: EvaluationContext, newContext: EvaluationContext): Promise<void> {
this._logger?.debug(`${GoFeatureFlagWebProvider.name}: new context provided: ${newContext}`); this._logger?.debug(`${GoFeatureFlagWebProvider.name}: new context provided: ${newContext}`);
this.events.emit(ProviderEvents.Stale, {message: 'context has changed'}); this.events.emit(ProviderEvents.Stale, { message: 'context has changed' });
await this.retryFetchAll(newContext); await this.retryFetchAll(newContext);
this.events.emit(ProviderEvents.Ready, {message: ''}); this.events.emit(ProviderEvents.Ready, { message: '' });
} }
resolveNumberEvaluation(flagKey: string): ResolutionDetails<number> { resolveNumberEvaluation(flagKey: string): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number') return this.evaluate(flagKey, 'number');
} }
resolveObjectEvaluation<T extends FlagValue>(flagKey: string): ResolutionDetails<T> { resolveObjectEvaluation<T extends FlagValue>(flagKey: string): ResolutionDetails<T> {
return this.evaluate(flagKey, 'object') return this.evaluate(flagKey, 'object');
} }
resolveStringEvaluation(flagKey: string): ResolutionDetails<string> { resolveStringEvaluation(flagKey: string): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string') return this.evaluate(flagKey, 'string');
} }
resolveBooleanEvaluation(flagKey: string): ResolutionDetails<boolean> { resolveBooleanEvaluation(flagKey: string): ResolutionDetails<boolean> {
return this.evaluate(flagKey, 'boolean') return this.evaluate(flagKey, 'boolean');
} }
private evaluate<T extends FlagValue>(flagKey: string, type: string): ResolutionDetails<T> { private evaluate<T extends FlagValue>(flagKey: string, type: string): ResolutionDetails<T> {
@ -236,13 +241,15 @@ export class GoFeatureFlagWebProvider implements Provider {
try { try {
await this.fetchAll(ctx, flagsChanged); await this.fetchAll(ctx, flagsChanged);
this._status = ProviderStatus.READY; this._status = ProviderStatus.READY;
return return;
} catch (err) { } catch (err) {
this._status = ProviderStatus.ERROR; this._status = ProviderStatus.ERROR;
this.handleFetchErrors(err) this.handleFetchErrors(err);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
delay *= this._retryDelayMultiplier; delay *= this._retryDelayMultiplier;
this._logger?.info(`${GoFeatureFlagWebProvider.name}: Waiting ${delay} ms before trying to evaluate the flags (${attempt}/${this._maxRetries}).`) this._logger?.info(
`${GoFeatureFlagWebProvider.name}: Waiting ${delay} ms before trying to evaluate the flags (${attempt}/${this._maxRetries}).`,
);
} }
} }
} }
@ -258,49 +265,51 @@ export class GoFeatureFlagWebProvider implements Provider {
private async fetchAll(context: EvaluationContext, flagsChanged: string[] = []) { private async fetchAll(context: EvaluationContext, flagsChanged: string[] = []) {
const endpointURL = new URL(this._endpoint); const endpointURL = new URL(this._endpoint);
const path = 'v1/allflags'; const path = 'v1/allflags';
endpointURL.pathname = endpointURL.pathname.endsWith('/') ? endpointURL.pathname + path : endpointURL.pathname + '/' + path; endpointURL.pathname = endpointURL.pathname.endsWith('/')
? endpointURL.pathname + path
: endpointURL.pathname + '/' + path;
const request: GoFeatureFlagAllFlagRequest = {evaluationContext: transformContext(context)}; const request: GoFeatureFlagAllFlagRequest = { evaluationContext: transformContext(context) };
const headers = new Headers({ const headers = new Headers({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}); });
if(this._apiKey){ if (this._apiKey) {
headers.set('Authorization', `Bearer ${this._apiKey}`); headers.set('Authorization', `Bearer ${this._apiKey}`);
} }
const init: RequestInit = { const init: RequestInit = {
method: "POST", method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(request) body: JSON.stringify(request),
}; };
const response = await fetch(endpointURL.toString(), init); const response = await fetch(endpointURL.toString(), init);
if(!response?.ok){ if (!response?.ok) {
throw new FetchError(response.status); throw new FetchError(response.status);
} }
const data = await response.json() as GOFeatureFlagAllFlagsResponse; const data = (await response.json()) as GOFeatureFlagAllFlagsResponse;
// In case we are in success // In case we are in success
let flags = {}; let flags = {};
Object.keys(data.flags).forEach(currentValue => { Object.keys(data.flags).forEach((currentValue) => {
const resolved: FlagState<FlagValue> = data.flags[currentValue]; const resolved: FlagState<FlagValue> = data.flags[currentValue];
const resolutionDetails: ResolutionDetails<FlagValue> = { const resolutionDetails: ResolutionDetails<FlagValue> = {
value: resolved.value, value: resolved.value,
variant: resolved.variationType, variant: resolved.variationType,
errorCode: resolved.errorCode, errorCode: resolved.errorCode,
flagMetadata: resolved.metadata, flagMetadata: resolved.metadata,
reason: resolved.reason reason: resolved.reason,
}; };
flags = { flags = {
...flags, ...flags,
[currentValue]: resolutionDetails [currentValue]: resolutionDetails,
}; };
}); });
const hasFlagsLoaded = this._flags !== undefined && Object.keys(this._flags).length !== 0 const hasFlagsLoaded = this._flags !== undefined && Object.keys(this._flags).length !== 0;
this._flags = flags; this._flags = flags;
if (hasFlagsLoaded) { if (hasFlagsLoaded) {
this.events.emit(ProviderEvents.ConfigurationChanged, { this.events.emit(ProviderEvents.ConfigurationChanged, {
@ -323,9 +332,13 @@ export class GoFeatureFlagWebProvider implements Provider {
message: error.message, message: error.message,
}); });
if (error.status == 401) { if (error.status == 401) {
this._logger?.error(`${GoFeatureFlagWebProvider.name}: invalid token used to contact GO Feature Flag instance: ${error}`); this._logger?.error(
`${GoFeatureFlagWebProvider.name}: invalid token used to contact GO Feature Flag instance: ${error}`,
);
} else if (error.status === 404) { } else if (error.status === 404) {
this._logger?.error(`${GoFeatureFlagWebProvider.name}: impossible to call go-feature-flag relay proxy ${error}`); this._logger?.error(
`${GoFeatureFlagWebProvider.name}: impossible to call go-feature-flag relay proxy ${error}`,
);
} else { } else {
this._logger?.error(`${GoFeatureFlagWebProvider.name}: unknown error while retrieving flags: ${error}`); this._logger?.error(`${GoFeatureFlagWebProvider.name}: unknown error while retrieving flags: ${error}`);
} }
@ -337,4 +350,3 @@ export class GoFeatureFlagWebProvider implements Provider {
} }
} }
} }

View File

@ -1,4 +1,4 @@
import {FlagValue, ErrorCode, EvaluationContextValue} from "@openfeature/web-sdk"; import { FlagValue, ErrorCode, EvaluationContextValue } from '@openfeature/web-sdk';
/** /**
* GoFeatureFlagEvaluationContext is the representation of a user for GO Feature Flag * GoFeatureFlagEvaluationContext is the representation of a user for GO Feature Flag
@ -20,7 +20,6 @@ export interface GoFeatureFlagAllFlagRequest {
evaluationContext: GoFeatureFlagEvaluationContext; evaluationContext: GoFeatureFlagEvaluationContext;
} }
/** /**
* GoFeatureFlagProviderOptions is the object containing all the provider options * GoFeatureFlagProviderOptions is the object containing all the provider options
* when initializing the open-feature provider. * when initializing the open-feature provider.
@ -52,7 +51,6 @@ export interface GoFeatureFlagWebProviderOptions {
maxRetries?: number; maxRetries?: number;
} }
/** /**
* FlagState is the object used to get the value return by GO Feature Flag. * FlagState is the object used to get the value return by GO Feature Flag.
*/ */
@ -73,15 +71,15 @@ export interface FlagState<T extends FlagValue> {
* by GO Feature Flag. * by GO Feature Flag.
*/ */
export interface GOFeatureFlagAllFlagsResponse { export interface GOFeatureFlagAllFlagsResponse {
valid: boolean valid: boolean;
flags: Record<string, FlagState<FlagValue>> flags: Record<string, FlagState<FlagValue>>;
} }
/** /**
* Format of the websocket event we can receive. * Format of the websocket event we can receive.
*/ */
export interface GOFeatureFlagWebsocketResponse { export interface GOFeatureFlagWebsocketResponse {
deleted?: { [key: string]: any } deleted?: { [key: string]: any };
added?: { [key: string]: any } added?: { [key: string]: any };
updated?: { [key: string]: any } updated?: { [key: string]: any };
} }

View File

@ -7,9 +7,7 @@ import { GoFeatureFlagUser } from './model';
* @param context - the context used for flag evaluation. * @param context - the context used for flag evaluation.
* @returns {GoFeatureFlagUser} the user against who we will evaluate the flag. * @returns {GoFeatureFlagUser} the user against who we will evaluate the flag.
*/ */
export function transformContext( export function transformContext(context: EvaluationContext): GoFeatureFlagUser {
context: EvaluationContext
): GoFeatureFlagUser {
const { targetingKey, ...attributes } = context; const { targetingKey, ...attributes } = context;
// If we don't have a targetingKey we are using a hash of the object to build // If we don't have a targetingKey we are using a hash of the object to build
@ -18,11 +16,7 @@ export function transformContext(
// Handle the special case of the anonymous field // Handle the special case of the anonymous field
let anonymous = false; let anonymous = false;
if ( if (attributes !== undefined && attributes !== null && 'anonymous' in attributes) {
attributes !== undefined &&
attributes !== null &&
'anonymous' in attributes
) {
if (typeof attributes['anonymous'] === 'boolean') { if (typeof attributes['anonymous'] === 'boolean') {
anonymous = attributes['anonymous']; anonymous = attributes['anonymous'];
} }

View File

@ -3,21 +3,16 @@ import {
FlagValue, FlagValue,
Hook, Hook,
HookContext, HookContext,
HookHints, Logger, StandardResolutionReasons, HookHints,
Logger,
StandardResolutionReasons,
} from '@openfeature/server-sdk'; } from '@openfeature/server-sdk';
import { import { DataCollectorHookOptions, DataCollectorRequest, DataCollectorResponse, FeatureEvent } from './model';
DataCollectorHookOptions, import { copy } from 'copy-anything';
DataCollectorRequest,
DataCollectorResponse,
FeatureEvent,
} from './model';
import {copy} from 'copy-anything';
import axios from 'axios'; import axios from 'axios';
const defaultTargetingKey = 'undefined-targetingKey'; const defaultTargetingKey = 'undefined-targetingKey';
export class GoFeatureFlagDataCollectorHook implements Hook { export class GoFeatureFlagDataCollectorHook implements Hook {
// bgSchedulerId contains the id of the setInterval that is running. // bgSchedulerId contains the id of the setInterval that is running.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@ -54,16 +49,15 @@ export class GoFeatureFlagDataCollectorHook implements Hook {
this.collectUnCachedEvaluation = options.collectUnCachedEvaluation; this.collectUnCachedEvaluation = options.collectUnCachedEvaluation;
} }
init() {
init(){ this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval);
this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval) this.dataCollectorBuffer = [];
this.dataCollectorBuffer = []
} }
async close() { async close() {
clearInterval(this.bgScheduler); clearInterval(this.bgScheduler);
// We call the data collector with what is still in the buffer. // We call the data collector with what is still in the buffer.
await this.callGoffDataCollection() await this.callGoffDataCollection();
} }
/** /**
@ -78,7 +72,7 @@ export class GoFeatureFlagDataCollectorHook implements Hook {
const dataToSend = copy(this.dataCollectorBuffer) || []; const dataToSend = copy(this.dataCollectorBuffer) || [];
this.dataCollectorBuffer = []; this.dataCollectorBuffer = [];
const request: DataCollectorRequest<boolean> = {events: dataToSend, meta: this.dataCollectorMetadata,} const request: DataCollectorRequest<boolean> = { events: dataToSend, meta: this.dataCollectorMetadata };
const endpointURL = new URL(this.endpoint); const endpointURL = new URL(this.endpoint);
endpointURL.pathname = 'v1/data/collector'; endpointURL.pathname = 'v1/data/collector';
@ -91,19 +85,14 @@ export class GoFeatureFlagDataCollectorHook implements Hook {
timeout: this.timeout, timeout: this.timeout,
}); });
} catch (e) { } catch (e) {
this.logger?.error(`impossible to send the data to the collector: ${e}`) this.logger?.error(`impossible to send the data to the collector: ${e}`);
// if we have an issue calling the collector we put the data back in the buffer // if we have an issue calling the collector we put the data back in the buffer
this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend]; this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend];
} }
} }
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>, hookHints?: HookHints) {
after( if (!this.collectUnCachedEvaluation && evaluationDetails.reason !== StandardResolutionReasons.CACHED) {
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
hookHints?: HookHints
) {
if (!this.collectUnCachedEvaluation && evaluationDetails.reason !== StandardResolutionReasons.CACHED){
return; return;
} }

View File

@ -1,13 +1,13 @@
import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk' import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk';
// ProxyNotReady is an error send when we try to call the relay proxy and he is not ready // ProxyNotReady is an error send when we try to call the relay proxy and he is not ready
// to return a valid response. // to return a valid response.
export class ProxyNotReady extends OpenFeatureError { export class ProxyNotReady extends OpenFeatureError {
code: ErrorCode code: ErrorCode;
constructor(message: string, originalError: Error) { constructor(message: string, originalError: Error) {
super(`${message}: ${originalError}`) super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, ProxyNotReady.prototype) Object.setPrototypeOf(this, ProxyNotReady.prototype);
this.code = ErrorCode.PROVIDER_NOT_READY this.code = ErrorCode.PROVIDER_NOT_READY;
} }
} }

View File

@ -1,13 +1,13 @@
import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk' import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk';
// ProxyTimeout is an error send when we try to call the relay proxy and he his not responding // ProxyTimeout is an error send when we try to call the relay proxy and he his not responding
// in the appropriate time. // in the appropriate time.
export class ProxyTimeout extends OpenFeatureError { export class ProxyTimeout extends OpenFeatureError {
code: ErrorCode code: ErrorCode;
constructor(message: string, originalError: Error) { constructor(message: string, originalError: Error) {
super(`${message}: ${originalError}`) super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, ProxyTimeout.prototype) Object.setPrototypeOf(this, ProxyTimeout.prototype);
this.code = ErrorCode.GENERAL this.code = ErrorCode.GENERAL;
} }
} }

View File

@ -1,12 +1,12 @@
import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk' import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk';
// Unauthorized is an error sent when the provider makes an unauthorized call to the relay proxy. // Unauthorized is an error sent when the provider makes an unauthorized call to the relay proxy.
export class Unauthorized extends OpenFeatureError { export class Unauthorized extends OpenFeatureError {
code: ErrorCode code: ErrorCode;
constructor(message: string) { constructor(message: string) {
super(message) super(message);
Object.setPrototypeOf(this, Unauthorized.prototype) Object.setPrototypeOf(this, Unauthorized.prototype);
this.code = ErrorCode.GENERAL this.code = ErrorCode.GENERAL;
} }
} }

View File

@ -1,12 +1,12 @@
import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk' import { ErrorCode, OpenFeatureError } from '@openfeature/server-sdk';
// UnknownError is an error send when something unexpected happened. // UnknownError is an error send when something unexpected happened.
export class UnknownError extends OpenFeatureError { export class UnknownError extends OpenFeatureError {
code: ErrorCode code: ErrorCode;
constructor(message: string, originalError: Error | unknown) { constructor(message: string, originalError: Error | unknown) {
super(`${message}: ${originalError}`) super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, UnknownError.prototype) Object.setPrototypeOf(this, UnknownError.prototype);
this.code = ErrorCode.GENERAL this.code = ErrorCode.GENERAL;
} }
} }

View File

@ -1,17 +1,11 @@
/** /**
* @jest-environment node * @jest-environment node
*/ */
import { import { Client, ErrorCode, OpenFeature, ProviderStatus, StandardResolutionReasons } from '@openfeature/server-sdk';
Client,
ErrorCode,
OpenFeature,
ProviderStatus,
StandardResolutionReasons,
} from '@openfeature/server-sdk';
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import {GoFeatureFlagProvider} from './go-feature-flag-provider'; import { GoFeatureFlagProvider } from './go-feature-flag-provider';
import {GoFeatureFlagProxyResponse} from './model'; import { GoFeatureFlagProxyResponse } from './model';
import TestLogger from './test-logger'; import TestLogger from './test-logger';
describe('GoFeatureFlagProvider', () => { describe('GoFeatureFlagProvider', () => {
@ -48,14 +42,14 @@ describe('GoFeatureFlagProvider', () => {
await OpenFeature.close(); await OpenFeature.close();
axiosMock.reset(); axiosMock.reset();
axiosMock.resetHistory(); axiosMock.resetHistory();
goff = new GoFeatureFlagProvider({endpoint}); goff = new GoFeatureFlagProvider({ endpoint });
OpenFeature.setProvider('test-provider', goff); OpenFeature.setProvider('test-provider', goff);
cli = OpenFeature.getClient('test-provider'); cli = OpenFeature.getClient('test-provider');
}); });
describe('common usecases and errors', () => { describe('common usecases and errors', () => {
it('should be an instance of GoFeatureFlagProvider', () => { it('should be an instance of GoFeatureFlagProvider', () => {
const goff = new GoFeatureFlagProvider({endpoint}); const goff = new GoFeatureFlagProvider({ endpoint });
expect(goff).toBeInstanceOf(GoFeatureFlagProvider); expect(goff).toBeInstanceOf(GoFeatureFlagProvider);
}); });
it('should throw an error if proxy not ready', async () => { it('should throw an error if proxy not ready', async () => {
@ -63,31 +57,31 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(404); axiosMock.onPost(dns).reply(404);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.PROVIDER_NOT_READY, errorCode: ErrorCode.PROVIDER_NOT_READY,
errorMessage: 'impossible to call go-feature-flag relay proxy on http://go-feature-flag-relay-proxy.local:1031/v1/feature/random-flag/eval: Error: Request failed with status code 404', errorMessage:
'impossible to call go-feature-flag relay proxy on http://go-feature-flag-relay-proxy.local:1031/v1/feature/random-flag/eval: Error: Request failed with status code 404',
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: false, value: false,
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
it('should throw an error if the call timeout', async () => { it('should throw an error if the call timeout', async () => {
const flagName = 'random-flag'; const flagName = 'random-flag';
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).timeout(); axiosMock.onPost(dns).timeout();
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.GENERAL, errorCode: ErrorCode.GENERAL,
errorMessage: 'impossible to retrieve the random-flag on time: Error: timeout of 0ms exceeded', errorMessage: 'impossible to retrieve the random-flag on time: Error: timeout of 0ms exceeded',
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: false, value: false,
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -101,14 +95,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
errorCode: ErrorCode.PROVIDER_NOT_READY, errorCode: ErrorCode.PROVIDER_NOT_READY,
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.PROVIDER_NOT_READY, errorCode: ErrorCode.PROVIDER_NOT_READY,
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.UNKNOWN, reason: StandardResolutionReasons.UNKNOWN,
value: true, value: true,
variant: 'trueVariation', variant: 'trueVariation',
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -121,14 +115,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
errorCode: 'NOT-AN-SDK-ERROR', errorCode: 'NOT-AN-SDK-ERROR',
} as unknown as GoFeatureFlagProxyResponse<boolean>); } as unknown as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.GENERAL, errorCode: ErrorCode.GENERAL,
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.UNKNOWN, reason: StandardResolutionReasons.UNKNOWN,
value: true, value: true,
variant: 'trueVariation', variant: 'trueVariation',
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -138,14 +132,14 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).networkError(); axiosMock.onPost(dns).networkError();
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.GENERAL, errorCode: ErrorCode.GENERAL,
errorMessage: `unknown error while retrieving flag ${flagName} for user ${targetingKey}: Error: Network Error`, errorMessage: `unknown error while retrieving flag ${flagName} for user ${targetingKey}: Error: Network Error`,
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: false, value: false,
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -159,14 +153,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
errorCode: ErrorCode.FLAG_NOT_FOUND, errorCode: ErrorCode.FLAG_NOT_FOUND,
} as GoFeatureFlagProxyResponse<string>); } as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'sdk-default', {targetingKey}) const res = await cli.getStringDetails(flagName, 'sdk-default', { targetingKey });
const want = { const want = {
errorCode: ErrorCode.FLAG_NOT_FOUND, errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: `Flag ${flagName} was not found in your configuration`, errorMessage: `Flag ${flagName} was not found in your configuration`,
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: 'sdk-default', value: 'sdk-default',
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -175,14 +169,14 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(401, {} as GoFeatureFlagProxyResponse<string>); axiosMock.onPost(dns).reply(401, {} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'sdk-default', {targetingKey}) const res = await cli.getStringDetails(flagName, 'sdk-default', { targetingKey });
const want = { const want = {
errorCode: ErrorCode.GENERAL, errorCode: ErrorCode.GENERAL,
errorMessage: 'invalid token used to contact GO Feature Flag relay proxy instance', errorMessage: 'invalid token used to contact GO Feature Flag relay proxy instance',
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: 'sdk-default', value: 'sdk-default',
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -199,18 +193,18 @@ describe('GoFeatureFlagProvider', () => {
trackEvents: true, trackEvents: true,
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
value: true, value: true,
variant: 'trueVariation', variant: 'trueVariation',
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
it('provider should start not ready', async () => { it('provider should start not ready', async () => {
const goff = new GoFeatureFlagProvider({endpoint}); const goff = new GoFeatureFlagProvider({ endpoint });
expect(goff.status).toEqual(ProviderStatus.NOT_READY); expect(goff.status).toEqual(ProviderStatus.NOT_READY);
}); });
it('provider should be ready after after setting the provider to Open Feature', async () => { it('provider should be ready after after setting the provider to Open Feature', async () => {
@ -224,20 +218,23 @@ describe('GoFeatureFlagProvider', () => {
axiosMock.onPost(dns).reply(200, validBoolResponse); axiosMock.onPost(dns).reply(200, validBoolResponse);
axiosMock.onPost(dataCollectorEndpoint).reply(500, {}); axiosMock.onPost(dataCollectorEndpoint).reply(500, {});
const goff = new GoFeatureFlagProvider({ const goff = new GoFeatureFlagProvider(
endpoint, {
flagCacheTTL: 3000, endpoint,
flagCacheSize: 100, flagCacheTTL: 3000,
dataFlushInterval: 2000, // in milliseconds flagCacheSize: 100,
}, testLogger) dataFlushInterval: 2000, // in milliseconds
},
testLogger,
);
expect(goff.status).toEqual(ProviderStatus.NOT_READY); expect(goff.status).toEqual(ProviderStatus.NOT_READY);
const got = await goff.resolveBooleanEvaluation(flagName, false, {targetingKey}); const got = await goff.resolveBooleanEvaluation(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.PROVIDER_NOT_READY, errorCode: ErrorCode.PROVIDER_NOT_READY,
errorMessage: 'Provider in a status that does not allow to serve flag: NOT_READY', errorMessage: 'Provider in a status that does not allow to serve flag: NOT_READY',
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: false value: false,
}; };
expect(got).toEqual(want); expect(got).toEqual(want);
}); });
@ -253,14 +250,14 @@ describe('GoFeatureFlagProvider', () => {
value: 'true', value: 'true',
variationType: 'trueVariation', variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<string>); } as GoFeatureFlagProxyResponse<string>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
errorCode: ErrorCode.TYPE_MISMATCH, errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: 'Flag value random-flag had unexpected type string, expected boolean.', errorMessage: 'Flag value random-flag had unexpected type string, expected boolean.',
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
value: false, value: false,
flagMetadata: {} flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -279,13 +276,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
value: true, value: true,
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -303,13 +300,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
value: true, value: true,
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -326,7 +323,7 @@ describe('GoFeatureFlagProvider', () => {
trackEvents: true, trackEvents: true,
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
@ -347,11 +344,11 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getStringDetails(flagName, 'false', {targetingKey}) const res = await cli.getStringDetails(flagName, 'false', { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
errorMessage:`Flag value ${flagName} had unexpected type boolean, expected string.`, errorMessage: `Flag value ${flagName} had unexpected type boolean, expected string.`,
errorCode: ErrorCode.TYPE_MISMATCH, errorCode: ErrorCode.TYPE_MISMATCH,
value: 'false', value: 'false',
flagMetadata: {}, flagMetadata: {},
@ -372,7 +369,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>); } as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'default', {targetingKey}) const res = await cli.getStringDetails(flagName, 'default', { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
@ -396,7 +393,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>); } as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'default', {targetingKey}) const res = await cli.getStringDetails(flagName, 'default', { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
@ -420,7 +417,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>); } as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'randomDefaultValue', {targetingKey}) const res = await cli.getStringDetails(flagName, 'randomDefaultValue', { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
@ -441,7 +438,7 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey}) const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
@ -466,12 +463,12 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>); } as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey}) const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
value: 14, value: 14,
variant:'trueVariation', variant: 'trueVariation',
flagMetadata: {}, flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
@ -490,12 +487,12 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>); } as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey}) const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
value: 14, value: 14,
variant:'trueVariation', variant: 'trueVariation',
flagMetadata: {}, flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
@ -514,7 +511,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>); } as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey}) const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
@ -535,7 +532,7 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation', variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getObjectDetails(flagName, {}, {targetingKey}) const res = await cli.getObjectDetails(flagName, {}, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
@ -552,7 +549,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, { axiosMock.onPost(dns).reply(200, {
value: {key: true}, value: { key: true },
variationType: 'trueVariation', variationType: 'trueVariation',
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
failed: false, failed: false,
@ -560,13 +557,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey}) const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
value: {key: true}, value: { key: true },
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -576,7 +573,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, { axiosMock.onPost(dns).reply(200, {
value: {key: true}, value: { key: true },
variationType: 'trueVariation', variationType: 'trueVariation',
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
failed: false, failed: false,
@ -584,13 +581,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey}) const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
value: {key: true}, value: { key: true },
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -600,7 +597,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`; const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, { axiosMock.onPost(dns).reply(200, {
value: {key: 123}, value: { key: 123 },
variationType: 'defaultSdk', variationType: 'defaultSdk',
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
failed: false, failed: false,
@ -608,11 +605,11 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey}) const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
value: {key: 'default'}, value: { key: 'default' },
flagMetadata: {}, flagMetadata: {},
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
@ -631,13 +628,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey}) const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
value: ['1', '2'], value: ['1', '2'],
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -655,14 +652,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.SPLIT, reason: StandardResolutionReasons.SPLIT,
value: ['1', '2'], value: ['1', '2'],
flagMetadata: {}, flagMetadata: {},
variant: 'trueVariation' variant: 'trueVariation',
}; };
expect(res).toEqual(want); expect(res).toEqual(want);
}); });
@ -680,7 +676,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0', version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>); } as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, ['key', '124'], {targetingKey}) const res = await cli.getObjectDetails(flagName, ['key', '124'], { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
@ -708,7 +704,7 @@ describe('GoFeatureFlagProvider', () => {
cacheable: true, cacheable: true,
} as GoFeatureFlagProxyResponse<boolean>); } as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey}) const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = { const want = {
flagKey: flagName, flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH, reason: StandardResolutionReasons.TARGETING_MATCH,
@ -734,11 +730,11 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000, flagCacheTTL: 3000,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey}); const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey });
const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey}); const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(got1.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH); expect(got1.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH);
expect(got2.reason).toEqual(StandardResolutionReasons.CACHED); expect(got2.reason).toEqual(StandardResolutionReasons.CACHED);
expect(axiosMock.history['post'].length).toBe(1); expect(axiosMock.history['post'].length).toBe(1);
@ -754,11 +750,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint, endpoint,
disableCache: true, disableCache: true,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey}); const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey });
const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey}); const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(got1).toEqual(got2); expect(got1).toEqual(got2);
expect(axiosMock.history['post'].length).toBe(2); expect(axiosMock.history['post'].length).toBe(2);
}); });
@ -775,12 +771,12 @@ describe('GoFeatureFlagProvider', () => {
endpoint, endpoint,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName1, false, {targetingKey}); await cli.getBooleanDetails(flagName1, false, { targetingKey });
await cli.getBooleanDetails(flagName2, false, {targetingKey}); await cli.getBooleanDetails(flagName2, false, { targetingKey });
await cli.getBooleanDetails(flagName1, false, {targetingKey}); await cli.getBooleanDetails(flagName1, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(3); expect(axiosMock.history['post'].length).toBe(3);
}); });
@ -788,16 +784,16 @@ describe('GoFeatureFlagProvider', () => {
const flagName = 'random-flag'; const flagName = 'random-flag';
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns1 = `${endpoint}v1/feature/${flagName}/eval`; const dns1 = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns1).reply(200, {...validBoolResponse, cacheable: false}); axiosMock.onPost(dns1).reply(200, { ...validBoolResponse, cacheable: false });
const goff = new GoFeatureFlagProvider({ const goff = new GoFeatureFlagProvider({
endpoint, endpoint,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(2); expect(axiosMock.history['post'].length).toBe(2);
}); });
@ -805,18 +801,18 @@ describe('GoFeatureFlagProvider', () => {
const flagName = 'random-flag'; const flagName = 'random-flag';
const targetingKey = 'user-key'; const targetingKey = 'user-key';
const dns1 = `${endpoint}v1/feature/${flagName}/eval`; const dns1 = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns1).reply(200, {...validBoolResponse}); axiosMock.onPost(dns1).reply(200, { ...validBoolResponse });
const goff = new GoFeatureFlagProvider({ const goff = new GoFeatureFlagProvider({
endpoint, endpoint,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
flagCacheTTL: 200, flagCacheTTL: 200,
}) });
OpenFeature.setProvider('test-provider-cache',goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(2); expect(axiosMock.history['post'].length).toBe(2);
}); });
@ -832,11 +828,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint, endpoint,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName1, false, {targetingKey}); await cli.getBooleanDetails(flagName1, false, { targetingKey });
await cli.getBooleanDetails(flagName2, false, {targetingKey}); await cli.getBooleanDetails(flagName2, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(2); expect(axiosMock.history['post'].length).toBe(2);
}); });
it('should not retrieve from the cache if context properties are different but same targeting key', async () => { it('should not retrieve from the cache if context properties are different but same targeting key', async () => {
@ -848,11 +844,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint, endpoint,
flagCacheSize: 1, flagCacheSize: 1,
disableDataCollection: true, disableDataCollection: true,
}) });
OpenFeature.setProvider('test-provider-cache', goff); OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache'); const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName1, false, {targetingKey, email: 'foo.bar@gofeatureflag.org'}); await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'foo.bar@gofeatureflag.org' });
await cli.getBooleanDetails(flagName1, false, {targetingKey, email: 'bar.foo@gofeatureflag.org'}); await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'bar.foo@gofeatureflag.org' });
expect(axiosMock.history['post'].length).toBe(2); expect(axiosMock.history['post'].length).toBe(2);
}); });
}); });
@ -868,28 +864,31 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000, flagCacheTTL: 3000,
flagCacheSize: 100, flagCacheSize: 100,
dataFlushInterval: 1000, // in milliseconds dataFlushInterval: 1000, // in milliseconds
}) });
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff); OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName); const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await OpenFeature.close() await OpenFeature.close();
const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls.length).toBe(1); expect(collectorCalls.length).toBe(1);
const got = JSON.parse(collectorCalls[0].data); const got = JSON.parse(collectorCalls[0].data);
expect(isNaN(got.events[0].creationDate)).toBe(false); expect(isNaN(got.events[0].creationDate)).toBe(false);
const want = { const want = {
events: [{ events: [
contextKind: 'user', {
kind: 'feature', contextKind: 'user',
creationDate: got.events[0].creationDate, kind: 'feature',
default: false, creationDate: got.events[0].creationDate,
key: 'random-flag', default: false,
value: true, key: 'random-flag',
variation: 'trueVariation', value: true,
userKey: 'user-key' variation: 'trueVariation',
}], meta: {provider: 'open-feature-js-sdk'} userKey: 'user-key',
},
],
meta: { provider: 'open-feature-js-sdk' },
}; };
expect(want).toEqual(got); expect(want).toEqual(got);
}); });
@ -905,14 +904,14 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000, flagCacheTTL: 3000,
flagCacheSize: 100, flagCacheSize: 100,
dataFlushInterval: 100, // in milliseconds dataFlushInterval: 100, // in milliseconds
}) });
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff); OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName); const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await new Promise((r) => setTimeout(r, 130)); await new Promise((r) => setTimeout(r, 130));
const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls.length).toBe(1); expect(collectorCalls.length).toBe(1);
}); });
@ -927,20 +926,20 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000, flagCacheTTL: 3000,
flagCacheSize: 100, flagCacheSize: 100,
dataFlushInterval: 100, // in milliseconds dataFlushInterval: 100, // in milliseconds
}) });
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff); OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName); const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await new Promise((r) => setTimeout(r, 130)); await new Promise((r) => setTimeout(r, 130));
const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls.length).toBe(1); expect(collectorCalls.length).toBe(1);
axiosMock.resetHistory(); axiosMock.resetHistory();
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await new Promise((r) => setTimeout(r, 130)); await new Promise((r) => setTimeout(r, 130));
const collectorCalls2 = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); const collectorCalls2 = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls2.length).toBe(1); expect(collectorCalls2.length).toBe(1);
}); });
@ -955,14 +954,14 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000, flagCacheTTL: 3000,
flagCacheSize: 100, flagCacheSize: 100,
dataFlushInterval: 200, // in milliseconds dataFlushInterval: 200, // in milliseconds
}) });
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff); OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName); const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await new Promise((r) => setTimeout(r, 130)); await new Promise((r) => setTimeout(r, 130));
const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint); const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls.length).toBe(0); expect(collectorCalls.length).toBe(0);
}); });
@ -975,21 +974,26 @@ describe('GoFeatureFlagProvider', () => {
axiosMock.onPost(dns).reply(200, validBoolResponse); axiosMock.onPost(dns).reply(200, validBoolResponse);
axiosMock.onPost(dataCollectorEndpoint).reply(500, {}); axiosMock.onPost(dataCollectorEndpoint).reply(500, {});
const goff = new GoFeatureFlagProvider({ const goff = new GoFeatureFlagProvider(
endpoint, {
flagCacheTTL: 3000, endpoint,
flagCacheSize: 100, flagCacheTTL: 3000,
dataFlushInterval: 2000, // in milliseconds flagCacheSize: 100,
}, testLogger) dataFlushInterval: 2000, // in milliseconds
},
testLogger,
);
const providerName = expect.getState().currentTestName || 'test'; const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff); OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName); const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, {targetingKey}); await cli.getBooleanDetails(flagName, false, { targetingKey });
await OpenFeature.close(); await OpenFeature.close();
expect(testLogger.inMemoryLogger['error'].length).toBe(1); expect(testLogger.inMemoryLogger['error'].length).toBe(1);
expect(testLogger.inMemoryLogger['error']).toContain('impossible to send the data to the collector: Error: Request failed with status code 500') expect(testLogger.inMemoryLogger['error']).toContain(
'impossible to send the data to the collector: Error: Request failed with status code 500',
);
}); });
}); });
}); });

View File

@ -12,19 +12,19 @@ import {
TypeMismatchError, TypeMismatchError,
} from '@openfeature/server-sdk'; } from '@openfeature/server-sdk';
import axios from 'axios'; import axios from 'axios';
import {transformContext} from './context-transformer'; import { transformContext } from './context-transformer';
import {ProxyNotReady} from './errors/proxyNotReady'; import { ProxyNotReady } from './errors/proxyNotReady';
import {ProxyTimeout} from './errors/proxyTimeout'; import { ProxyTimeout } from './errors/proxyTimeout';
import {UnknownError} from './errors/unknownError'; import { UnknownError } from './errors/unknownError';
import {Unauthorized} from './errors/unauthorized'; import { Unauthorized } from './errors/unauthorized';
import {LRUCache} from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { import {
GoFeatureFlagProviderOptions, GoFeatureFlagProviderOptions,
GoFeatureFlagProxyRequest, GoFeatureFlagProxyRequest,
GoFeatureFlagProxyResponse, GoFeatureFlagProxyResponse,
GoFeatureFlagUser, GoFeatureFlagUser,
} from './model'; } from './model';
import {GoFeatureFlagDataCollectorHook} from './data-collector-hook'; import { GoFeatureFlagDataCollectorHook } from './data-collector-hook';
import hash from 'object-hash'; import hash from 'object-hash';
// GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag. // GoFeatureFlagProvider is the official Open-feature provider for GO Feature Flag.
@ -63,12 +63,15 @@ export class GoFeatureFlagProvider implements Provider {
this.timeout = options.timeout || 0; // default is 0 = no timeout this.timeout = options.timeout || 0; // default is 0 = no timeout
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
if (!options.disableDataCollection){ if (!options.disableDataCollection) {
this.dataCollectorHook = new GoFeatureFlagDataCollectorHook({ this.dataCollectorHook = new GoFeatureFlagDataCollectorHook(
endpoint: this.endpoint, {
timeout: this.timeout, endpoint: this.endpoint,
dataFlushInterval: options.dataFlushInterval timeout: this.timeout,
}, logger); dataFlushInterval: options.dataFlushInterval,
},
logger,
);
this.hooks = [this.dataCollectorHook]; this.hooks = [this.dataCollectorHook];
} }
@ -83,8 +86,9 @@ export class GoFeatureFlagProvider implements Provider {
} }
if (!options.disableCache) { if (!options.disableCache) {
const cacheSize = options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000; const cacheSize =
this.cache = new LRUCache({maxSize: cacheSize, sizeCalculation: () => 1}); options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000;
this.cache = new LRUCache({ maxSize: cacheSize, sizeCalculation: () => 1 });
} }
} }
@ -94,12 +98,12 @@ export class GoFeatureFlagProvider implements Provider {
*/ */
async initialize() { async initialize() {
if (!this.disableDataCollection) { if (!this.disableDataCollection) {
this.dataCollectorHook?.init() this.dataCollectorHook?.init();
} }
this._status = ProviderStatus.READY; this._status = ProviderStatus.READY;
} }
get status(){ get status() {
return this._status; return this._status;
} }
@ -126,13 +130,13 @@ export class GoFeatureFlagProvider implements Provider {
async resolveBooleanEvaluation( async resolveBooleanEvaluation(
flagKey: string, flagKey: string,
defaultValue: boolean, defaultValue: boolean,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> { ): Promise<ResolutionDetails<boolean>> {
return this.resolveEvaluationGoFeatureFlagProxy<boolean>( return this.resolveEvaluationGoFeatureFlagProxy<boolean>(
flagKey, flagKey,
defaultValue, defaultValue,
transformContext(context), transformContext(context),
'boolean' 'boolean',
); );
} }
@ -151,14 +155,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveStringEvaluation( async resolveStringEvaluation(
flagKey: string, flagKey: string,
defaultValue: string, defaultValue: string,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<string>> { ): Promise<ResolutionDetails<string>> {
return this.resolveEvaluationGoFeatureFlagProxy<string>( return this.resolveEvaluationGoFeatureFlagProxy<string>(flagKey, defaultValue, transformContext(context), 'string');
flagKey,
defaultValue,
transformContext(context),
'string'
);
} }
/** /**
@ -176,14 +175,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveNumberEvaluation( async resolveNumberEvaluation(
flagKey: string, flagKey: string,
defaultValue: number, defaultValue: number,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<number>> { ): Promise<ResolutionDetails<number>> {
return this.resolveEvaluationGoFeatureFlagProxy<number>( return this.resolveEvaluationGoFeatureFlagProxy<number>(flagKey, defaultValue, transformContext(context), 'number');
flagKey,
defaultValue,
transformContext(context),
'number'
);
} }
/** /**
@ -201,14 +195,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveObjectEvaluation<U extends JsonValue>( async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string, flagKey: string,
defaultValue: U, defaultValue: U,
context: EvaluationContext context: EvaluationContext,
): Promise<ResolutionDetails<U>> { ): Promise<ResolutionDetails<U>> {
return this.resolveEvaluationGoFeatureFlagProxy<U>( return this.resolveEvaluationGoFeatureFlagProxy<U>(flagKey, defaultValue, transformContext(context), 'object');
flagKey,
defaultValue,
transformContext(context),
'object'
);
} }
/** /**
@ -230,10 +219,10 @@ export class GoFeatureFlagProvider implements Provider {
flagKey: string, flagKey: string,
defaultValue: T, defaultValue: T,
user: GoFeatureFlagUser, user: GoFeatureFlagUser,
expectedType: string expectedType: string,
): Promise<ResolutionDetails<T>> { ): Promise<ResolutionDetails<T>> {
// Check if the provider is ready to serve // Check if the provider is ready to serve
if(this._status === ProviderStatus.NOT_READY){ if (this._status === ProviderStatus.NOT_READY) {
return { return {
value: defaultValue, value: defaultValue,
reason: StandardResolutionReasons.ERROR, reason: StandardResolutionReasons.ERROR,
@ -252,7 +241,7 @@ export class GoFeatureFlagProvider implements Provider {
} }
} }
const request: GoFeatureFlagProxyRequest<T> = {user, defaultValue}; const request: GoFeatureFlagProxyRequest<T> = { user, defaultValue };
// build URL to access to the endpoint // build URL to access to the endpoint
const endpointURL = new URL(this.endpoint); const endpointURL = new URL(this.endpoint);
endpointURL.pathname = `v1/feature/${flagKey}/eval`; endpointURL.pathname = `v1/feature/${flagKey}/eval`;
@ -272,49 +261,35 @@ export class GoFeatureFlagProvider implements Provider {
throw new Unauthorized('invalid token used to contact GO Feature Flag relay proxy instance'); throw new Unauthorized('invalid token used to contact GO Feature Flag relay proxy instance');
} }
// Impossible to contact the relay-proxy // Impossible to contact the relay-proxy
if ( if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.response?.status === 404)) {
axios.isAxiosError(error) && throw new ProxyNotReady(`impossible to call go-feature-flag relay proxy on ${endpointURL}`, error);
(error.code === 'ECONNREFUSED' || error.response?.status === 404)
) {
throw new ProxyNotReady(
`impossible to call go-feature-flag relay proxy on ${endpointURL}`,
error
);
} }
// Timeout when calling the API // Timeout when calling the API
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
throw new ProxyTimeout( throw new ProxyTimeout(`impossible to retrieve the ${flagKey} on time`, error);
`impossible to retrieve the ${flagKey} on time`,
error
);
} }
throw new UnknownError( throw new UnknownError(`unknown error while retrieving flag ${flagKey} for user ${user.key}`, error);
`unknown error while retrieving flag ${flagKey} for user ${user.key}`,
error
);
} }
// Check that we received the expectedType // Check that we received the expectedType
if (typeof apiResponseData.value !== expectedType) { if (typeof apiResponseData.value !== expectedType) {
throw new TypeMismatchError( throw new TypeMismatchError(
`Flag value ${flagKey} had unexpected type ${typeof apiResponseData.value}, expected ${expectedType}.` `Flag value ${flagKey} had unexpected type ${typeof apiResponseData.value}, expected ${expectedType}.`,
); );
} }
// Case of the flag is not found // Case of the flag is not found
if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) { if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) {
throw new FlagNotFoundError( throw new FlagNotFoundError(`Flag ${flagKey} was not found in your configuration`);
`Flag ${flagKey} was not found in your configuration`
);
} }
// Case of the flag is disabled // Case of the flag is disabled
if (apiResponseData.reason === StandardResolutionReasons.DISABLED) { if (apiResponseData.reason === StandardResolutionReasons.DISABLED) {
// we don't set a variant since we are using the default value, and we are not able to know // we don't set a variant since we are using the default value, and we are not able to know
// which variant it is. // which variant it is.
return {value: defaultValue, reason: apiResponseData.reason}; return { value: defaultValue, reason: apiResponseData.reason };
} }
const sdkResponse: ResolutionDetails<T> = { const sdkResponse: ResolutionDetails<T> = {
@ -331,9 +306,9 @@ export class GoFeatureFlagProvider implements Provider {
if (this.cache !== undefined && apiResponseData.cacheable) { if (this.cache !== undefined && apiResponseData.cacheable) {
if (this.cacheTTL === -1) { if (this.cacheTTL === -1) {
this.cache.set(cacheKey, sdkResponse) this.cache.set(cacheKey, sdkResponse);
} else { } else {
this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL}) this.cache.set(cacheKey, sdkResponse, { ttl: this.cacheTTL });
} }
} }
return sdkResponse; return sdkResponse;

View File

@ -1,7 +1,4 @@
import { import { ErrorCode, EvaluationContextValue } from '@openfeature/server-sdk';
ErrorCode,
EvaluationContextValue,
} from '@openfeature/server-sdk';
/** /**
* GoFeatureFlagUser is the representation of a user for GO Feature Flag * GoFeatureFlagUser is the representation of a user for GO Feature Flag
@ -57,25 +54,25 @@ export interface GoFeatureFlagProviderOptions {
apiKey?: string; apiKey?: string;
// disableCache (optional) set to true if you would like that every flag evaluation goes to the GO Feature Flag directly. // disableCache (optional) set to true if you would like that every flag evaluation goes to the GO Feature Flag directly.
disableCache?: boolean disableCache?: boolean;
// flagCacheSize (optional) is the maximum number of flag events we keep in memory to cache your flags. // flagCacheSize (optional) is the maximum number of flag events we keep in memory to cache your flags.
// default: 10000 // default: 10000
flagCacheSize?: number flagCacheSize?: number;
// flagCacheTTL (optional) is the time we keep the evaluation in the cache before we consider it as obsolete. // flagCacheTTL (optional) is the time we keep the evaluation in the cache before we consider it as obsolete.
// If you want to keep the value forever you can set the FlagCacheTTL field to -1 // If you want to keep the value forever you can set the FlagCacheTTL field to -1
// default: 1 minute // default: 1 minute
flagCacheTTL?: number flagCacheTTL?: number;
// dataFlushInterval (optional) interval time (in millisecond) we use to call the relay proxy to collect data. // dataFlushInterval (optional) interval time (in millisecond) we use to call the relay proxy to collect data.
// The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly // The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly
// when calling the evaluation API. // when calling the evaluation API.
// default: 1 minute // default: 1 minute
dataFlushInterval?: number dataFlushInterval?: number;
// disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache.
disableDataCollection?: boolean disableDataCollection?: boolean;
} }
// GOFeatureFlagResolutionReasons allows to extends resolution reasons // GOFeatureFlagResolutionReasons allows to extends resolution reasons
@ -84,7 +81,6 @@ export declare enum GOFeatureFlagResolutionReasons {}
// GOFeatureFlagErrorCode allows to extends error codes // GOFeatureFlagErrorCode allows to extends error codes
export declare enum GOFeatureFlagErrorCode {} export declare enum GOFeatureFlagErrorCode {}
export interface DataCollectorRequest<T> { export interface DataCollectorRequest<T> {
events: FeatureEvent<T>[]; events: FeatureEvent<T>[];
meta: Record<string, string>; meta: Record<string, string>;

View File

@ -19,21 +19,21 @@ const logger: TestLogger = new TestLogger();
const testFlagKey = 'a-key'; const testFlagKey = 'a-key';
describe('LaunchDarklyClientProvider', () => { describe('LaunchDarklyClientProvider', () => {
let ldProvider: LaunchDarklyClientProvider let ldProvider: LaunchDarklyClientProvider;
let ofClient: Client; let ofClient: Client;
const ldClientMock: jest.Mocked<LDClient> = { const ldClientMock: jest.Mocked<LDClient> = {
variationDetail: jest.fn(), variationDetail: jest.fn(),
identify: jest.fn(), identify: jest.fn(),
waitForInitialization: jest.fn(), waitForInitialization: jest.fn(),
on: jest.fn(), on: jest.fn(),
close: jest.fn(), close: jest.fn(),
} as unknown as jest.Mocked<LDClient>; } as unknown as jest.Mocked<LDClient>;
beforeAll(() => { beforeAll(() => {
ldProvider = new LaunchDarklyClientProvider('test-env-key', { logger }); ldProvider = new LaunchDarklyClientProvider('test-env-key', { logger });
OpenFeature.setProvider(ldProvider); OpenFeature.setProvider(ldProvider);
ofClient = OpenFeature.getClient(); ofClient = OpenFeature.getClient();
}) });
beforeEach(() => { beforeEach(() => {
logger.reset(); logger.reset();
jest.clearAllMocks(); jest.clearAllMocks();
@ -85,7 +85,7 @@ describe('LaunchDarklyClientProvider', () => {
it('should set the status to READY if initialization succeeds', async () => { it('should set the status to READY if initialization succeeds', async () => {
ldClientMock.waitForInitialization.mockResolvedValue(); ldClientMock.waitForInitialization.mockResolvedValue();
await provider.initialize(); await provider.initialize();
expect( ldClientMock.waitForInitialization).toHaveBeenCalledTimes(1); expect(ldClientMock.waitForInitialization).toHaveBeenCalledTimes(1);
expect(provider.status).toBe('READY'); expect(provider.status).toBe('READY');
}); });
@ -94,7 +94,6 @@ describe('LaunchDarklyClientProvider', () => {
await provider.initialize(); await provider.initialize();
expect(provider.status).toBe('ERROR'); expect(provider.status).toBe('ERROR');
}); });
}); });
describe('resolveBooleanEvaluation', () => { describe('resolveBooleanEvaluation', () => {
@ -106,12 +105,10 @@ describe('LaunchDarklyClientProvider', () => {
}, },
}); });
ofClient.getBooleanDetails(testFlagKey, false); ofClient.getBooleanDetails(testFlagKey, false);
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, false);
.toHaveBeenCalledWith(testFlagKey, false);
jest.clearAllMocks(); jest.clearAllMocks();
ofClient.getBooleanValue(testFlagKey, false); ofClient.getBooleanValue(testFlagKey, false);
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, false);
.toHaveBeenCalledWith(testFlagKey, false);
}); });
it('handles correct return types for boolean variations', () => { it('handles correct return types for boolean variations', () => {
@ -150,7 +147,6 @@ describe('LaunchDarklyClientProvider', () => {
errorCode: 'TYPE_MISMATCH', errorCode: 'TYPE_MISMATCH',
}); });
}); });
}); });
describe('resolveNumberEvaluation', () => { describe('resolveNumberEvaluation', () => {
@ -163,12 +159,10 @@ describe('LaunchDarklyClientProvider', () => {
}); });
ofClient.getNumberDetails(testFlagKey, 0); ofClient.getNumberDetails(testFlagKey, 0);
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, 0);
.toHaveBeenCalledWith(testFlagKey, 0);
jest.clearAllMocks(); jest.clearAllMocks();
ofClient.getNumberValue(testFlagKey, 0); ofClient.getNumberValue(testFlagKey, 0);
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, 0);
.toHaveBeenCalledWith(testFlagKey, 0);
}); });
it('handles correct return types for numeric variations', () => { it('handles correct return types for numeric variations', () => {
@ -219,12 +213,10 @@ describe('LaunchDarklyClientProvider', () => {
}); });
ofClient.getObjectDetails(testFlagKey, {}); ofClient.getObjectDetails(testFlagKey, {});
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, {});
.toHaveBeenCalledWith(testFlagKey, {});
jest.clearAllMocks(); jest.clearAllMocks();
ofClient.getObjectValue(testFlagKey, {}); ofClient.getObjectValue(testFlagKey, {});
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, {});
.toHaveBeenCalledWith(testFlagKey, {});
}); });
it('handles correct return types for object variations', () => { it('handles correct return types for object variations', () => {
@ -263,9 +255,9 @@ describe('LaunchDarklyClientProvider', () => {
errorCode: 'TYPE_MISMATCH', errorCode: 'TYPE_MISMATCH',
}); });
}); });
}) });
describe('resolveStringEvaluation', ( ) => { describe('resolveStringEvaluation', () => {
it('calls the client correctly for string variations', () => { it('calls the client correctly for string variations', () => {
ldClientMock.variationDetail = jest.fn().mockReturnValue({ ldClientMock.variationDetail = jest.fn().mockReturnValue({
value: 'test', value: 'test',
@ -275,12 +267,10 @@ describe('LaunchDarklyClientProvider', () => {
}); });
ofClient.getStringDetails(testFlagKey, 'default'); ofClient.getStringDetails(testFlagKey, 'default');
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, 'default');
.toHaveBeenCalledWith(testFlagKey, 'default');
jest.clearAllMocks(); jest.clearAllMocks();
ofClient.getStringValue(testFlagKey, 'default'); ofClient.getStringValue(testFlagKey, 'default');
expect(ldClientMock.variationDetail) expect(ldClientMock.variationDetail).toHaveBeenCalledWith(testFlagKey, 'default');
.toHaveBeenCalledWith(testFlagKey, 'default');
}); });
it('handles correct return types for string variations', () => { it('handles correct return types for string variations', () => {
@ -310,7 +300,7 @@ describe('LaunchDarklyClientProvider', () => {
}, },
}); });
const res = ofClient.getStringDetails(testFlagKey, 'default'); const res = ofClient.getStringDetails(testFlagKey, 'default');
expect(res).toEqual({ expect(res).toEqual({
flagKey: testFlagKey, flagKey: testFlagKey,
flagMetadata: {}, flagMetadata: {},
@ -371,17 +361,20 @@ describe('LaunchDarklyClientProvider', () => {
it('logs information about missing keys', async () => { it('logs information about missing keys', async () => {
ldClientMock.identify = jest.fn().mockResolvedValue({}); ldClientMock.identify = jest.fn().mockResolvedValue({});
await OpenFeature.setContext({}); await OpenFeature.setContext({});
expect(ldClientMock.identify).toHaveBeenCalledWith(translateContext(logger, {})) expect(ldClientMock.identify).toHaveBeenCalledWith(translateContext(logger, {}));
expect(logger.logs[0]).toEqual("The EvaluationContext must contain either a 'targetingKey' " expect(logger.logs[0]).toEqual(
+ "or a 'key' and the type must be a string."); "The EvaluationContext must contain either a 'targetingKey' " + "or a 'key' and the type must be a string.",
);
}); });
it('logs information about double keys', async () => { it('logs information about double keys', async () => {
ldClientMock.identify = jest.fn().mockResolvedValue({}); ldClientMock.identify = jest.fn().mockResolvedValue({});
await OpenFeature.setContext({ targetingKey: '1', key: '2' }); await OpenFeature.setContext({ targetingKey: '1', key: '2' });
expect(ldClientMock.identify).toHaveBeenCalledWith(translateContext(logger, { targetingKey: '1', key: '2' })) expect(ldClientMock.identify).toHaveBeenCalledWith(translateContext(logger, { targetingKey: '1', key: '2' }));
expect(logger.logs[0]).toEqual("The EvaluationContext contained both a 'targetingKey' and a" expect(logger.logs[0]).toEqual(
+ " 'key' attribute. The 'key' attribute will be discarded."); "The EvaluationContext contained both a 'targetingKey' and a" +
" 'key' attribute. The 'key' attribute will be discarded.",
);
}); });
}); });
}); });

View File

@ -10,7 +10,7 @@ import {
GeneralError, GeneralError,
OpenFeatureEventEmitter, OpenFeatureEventEmitter,
ProviderEvents, ProviderEvents,
ProviderStatus ProviderStatus,
} from '@openfeature/web-sdk'; } from '@openfeature/web-sdk';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
@ -21,7 +21,6 @@ import { LaunchDarklyProviderOptions } from './launchdarkly-provider-options';
import translateContext from './translate-context'; import translateContext from './translate-context';
import translateResult from './translate-result'; import translateResult from './translate-result';
/** /**
* Create a ResolutionDetails for an evaluation that produced a type different * Create a ResolutionDetails for an evaluation that produced a type different
* from the expected type. * from the expected type.
@ -63,14 +62,14 @@ export class LaunchDarklyClientProvider implements Provider {
constructor( constructor(
private readonly envKey: string, private readonly envKey: string,
{ logger, ...ldOptions }: LaunchDarklyProviderOptions) { { logger, ...ldOptions }: LaunchDarklyProviderOptions,
) {
if (logger) { if (logger) {
this.logger = logger; this.logger = logger;
} else { } else {
this.logger = basicLogger({ level: 'info' }); this.logger = basicLogger({ level: 'info' });
} }
this.ldOptions = { ...ldOptions, logger: this.logger }; this.ldOptions = { ...ldOptions, logger: this.logger };
} }
private get client(): LDClient { private get client(): LDClient {

View File

@ -26,10 +26,7 @@ describe('translateContext', () => {
it('gives targetingKey precedence over key', () => { it('gives targetingKey precedence over key', () => {
const logger = new TestLogger(); const logger = new TestLogger();
expect(translateContext( expect(translateContext(logger, { targetingKey: 'target-key', key: 'key-key' })).toEqual({
logger,
{ targetingKey: 'target-key', key: 'key-key' },
)).toEqual({
key: 'target-key', key: 'target-key',
kind: 'user', kind: 'user',
}); });
@ -49,10 +46,7 @@ describe('translateContext', () => {
])('given correct built-in attributes', (key, value) => { ])('given correct built-in attributes', (key, value) => {
const logger = new TestLogger(); const logger = new TestLogger();
it('translates the key correctly', () => { it('translates the key correctly', () => {
expect(translateContext( expect(translateContext(logger, { targetingKey: 'the-key', [key]: value })).toEqual({
logger,
{ targetingKey: 'the-key', [key]: value },
)).toEqual({
key: 'the-key', key: 'the-key',
[key]: value, [key]: value,
kind: 'user', kind: 'user',
@ -63,10 +57,7 @@ describe('translateContext', () => {
it.each(['key', 'targetingKey'])('handles key or targetingKey', (key) => { it.each(['key', 'targetingKey'])('handles key or targetingKey', (key) => {
const logger = new TestLogger(); const logger = new TestLogger();
expect(translateContext( expect(translateContext(logger, { [key]: 'the-key' })).toEqual({
logger,
{ [key]: 'the-key' },
)).toEqual({
key: 'the-key', key: 'the-key',
kind: 'user', kind: 'user',
}); });
@ -79,10 +70,7 @@ describe('translateContext', () => {
])('given incorrect built-in attributes', (key, value) => { ])('given incorrect built-in attributes', (key, value) => {
it('the bad key is omitted', () => { it('the bad key is omitted', () => {
const logger = new TestLogger(); const logger = new TestLogger();
expect(translateContext( expect(translateContext(logger, { targetingKey: 'the-key', [key]: value })).toEqual({
logger,
{ targetingKey: 'the-key', [key]: value },
)).toEqual({
key: 'the-key', key: 'the-key',
kind: 'user', kind: 'user',
}); });
@ -102,12 +90,14 @@ describe('translateContext', () => {
it('accepts string/boolean/number arrays', () => { it('accepts string/boolean/number arrays', () => {
const logger = new TestLogger(); const logger = new TestLogger();
expect(translateContext(logger, { expect(
targetingKey: 'the-key', translateContext(logger, {
strings: ['a', 'b', 'c'], targetingKey: 'the-key',
numbers: [1, 2, 3], strings: ['a', 'b', 'c'],
booleans: [true, false], numbers: [1, 2, 3],
})).toEqual({ booleans: [true, false],
}),
).toEqual({
key: 'the-key', key: 'the-key',
kind: 'user', kind: 'user',
strings: ['a', 'b', 'c'], strings: ['a', 'b', 'c'],
@ -120,10 +110,7 @@ describe('translateContext', () => {
it('converts date to ISO strings', () => { it('converts date to ISO strings', () => {
const date = new Date(); const date = new Date();
const logger = new TestLogger(); const logger = new TestLogger();
expect(translateContext( expect(translateContext(logger, { targetingKey: 'the-key', date })).toEqual({
logger,
{ targetingKey: 'the-key', date },
)).toEqual({
key: 'the-key', key: 'the-key',
kind: 'user', kind: 'user',
date: date.toISOString(), date: date.toISOString(),

View File

@ -30,19 +30,15 @@ const LDContextBuiltIns = {
* @param object Object to place the value in. * @param object Object to place the value in.
* @param visited Carry visited keys of the object * @param visited Carry visited keys of the object
*/ */
function convertAttributes( function convertAttributes(logger: LDLogger, key: string, value: any, object: any, visited: any[]): any {
logger: LDLogger,
key: string,
value: any,
object: any,
visited: any[],
): any {
if (visited.includes(value)) { if (visited.includes(value)) {
// Prevent cycles by not visiting the same object // Prevent cycles by not visiting the same object
// with in the same branch. Different branches // with in the same branch. Different branches
// may contain the same object. // may contain the same object.
logger.error('Detected a cycle within the evaluation context. The ' logger.error(
+ 'affected part of the context will not be included in evaluation.'); 'Detected a cycle within the evaluation context. The ' +
'affected part of the context will not be included in evaluation.',
);
return; return;
} }
// This method is recursively populating objects, so we are intentionally // This method is recursively populating objects, so we are intentionally
@ -81,13 +77,16 @@ function translateContextCommon(
const finalKey = inTargetingKey ?? keyAttr; const finalKey = inTargetingKey ?? keyAttr;
if (keyAttr != null && inTargetingKey != null) { if (keyAttr != null && inTargetingKey != null) {
logger.warn("The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The" logger.warn(
+ " 'key' attribute will be discarded."); "The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The" +
" 'key' attribute will be discarded.",
);
} }
if (finalKey == null) { if (finalKey == null) {
logger.error("The EvaluationContext must contain either a 'targetingKey' or a 'key' and the " logger.error(
+ 'type must be a string.'); "The EvaluationContext must contain either a 'targetingKey' or a 'key' and the " + 'type must be a string.',
);
} }
const convertedContext: LDContextCommon = { key: finalKey }; const convertedContext: LDContextCommon = { key: finalKey };
@ -101,7 +100,7 @@ function translateContextCommon(
privateAttributes: value as string[], privateAttributes: value as string[],
}; };
} else if (key in LDContextBuiltIns) { } else if (key in LDContextBuiltIns) {
const typedKey = key as 'name'| 'anonymous'; const typedKey = key as 'name' | 'anonymous';
if (typeof value === LDContextBuiltIns[typedKey]) { if (typeof value === LDContextBuiltIns[typedKey]) {
convertedContext[key] = value; convertedContext[key] = value;
} else { } else {
@ -124,31 +123,24 @@ function translateContextCommon(
* *
* @internal * @internal
*/ */
export default function translateContext( export default function translateContext(logger: LDLogger, evalContext: EvaluationContext): LDContext {
logger: LDLogger,
evalContext: EvaluationContext,
): LDContext {
let finalKind = 'user'; let finalKind = 'user';
// A multi-context. // A multi-context.
if (evalContext['kind'] === 'multi') { if (evalContext['kind'] === 'multi') {
return Object.entries(evalContext) return Object.entries(evalContext).reduce((acc: any, [key, value]: [string, EvaluationContextValue]) => {
.reduce((acc: any, [key, value]: [string, EvaluationContextValue]) => { if (key === 'kind') {
if (key === 'kind') { acc.kind = value;
acc.kind = value; } else if (typeof value === 'object' && !Array.isArray(value)) {
} else if (typeof value === 'object' && !Array.isArray(value)) { const valueRecord = value as Record<string, EvaluationContextValue>;
const valueRecord = value as Record<string, EvaluationContextValue>; acc[key] = translateContextCommon(logger, valueRecord, valueRecord['targetingKey'] as string);
acc[key] = translateContextCommon( } else {
logger, logger.error('Top level attributes in a multi-kind context should be Structure types.');
valueRecord, }
valueRecord['targetingKey'] as string, return acc;
); }, {});
} else { }
logger.error('Top level attributes in a multi-kind context should be Structure types.'); if (evalContext['kind'] !== undefined && typeof evalContext['kind'] === 'string') {
}
return acc;
}, {});
} if (evalContext['kind'] !== undefined && typeof evalContext['kind'] === 'string') {
// Single context with specified kind. // Single context with specified kind.
finalKind = evalContext['kind']; finalKind = evalContext['kind'];
} else if (evalContext['kind'] !== undefined && typeof evalContext['kind'] !== 'string') { } else if (evalContext['kind'] !== undefined && typeof evalContext['kind'] !== 'string') {

View File

@ -1,45 +1,43 @@
import translateResult from './translate-result'; import translateResult from './translate-result';
describe('translateResult', () => { describe('translateResult', () => {
it.each([ it.each([true, 'potato', 42, { yes: 'no' }])('puts the value into the result.', (value) => {
true, expect(
'potato', translateResult<typeof value>({
42, value,
{ yes: 'no' }, reason: {
])('puts the value into the result.', (value) => { kind: 'OFF',
expect(translateResult<typeof value>({ },
value, }).value,
reason: { ).toEqual(value);
kind: 'OFF',
},
}).value).toEqual(value);
}); });
it('converts the variationIndex into a string variant', () => { it('converts the variationIndex into a string variant', () => {
expect(translateResult<boolean>({ expect(
value: true, translateResult<boolean>({
variationIndex: 9, value: true,
reason: { variationIndex: 9,
kind: 'OFF', reason: {
}, kind: 'OFF',
}).variant).toEqual('9'); },
}).variant,
).toEqual('9');
}); });
it.each([ it.each(['OFF', 'FALLTHROUGH', 'TARGET_MATCH', 'PREREQUISITE_FAILED', 'ERROR'])(
'OFF', 'populates the resolution reason',
'FALLTHROUGH', (reason) => {
'TARGET_MATCH', expect(
'PREREQUISITE_FAILED', translateResult<boolean>({
'ERROR', value: true,
])('populates the resolution reason', (reason) => { variationIndex: 9,
expect(translateResult<boolean>({ reason: {
value: true, kind: reason,
variationIndex: 9, },
reason: { }).reason,
kind: reason, ).toEqual(reason);
}, },
}).reason).toEqual(reason); );
});
it('does not populate the errorCode when there is not an error', () => { it('does not populate the errorCode when there is not an error', () => {
const translated = translateResult<boolean>({ const translated = translateResult<boolean>({

View File

@ -1,4 +1,4 @@
import {FeatureFlag, Flag} from './feature-flag'; import { FeatureFlag, Flag } from './feature-flag';
describe('Flagd flag structure', () => { describe('Flagd flag structure', () => {
it('should be constructed with valid input - boolean', () => { it('should be constructed with valid input - boolean', () => {
@ -66,7 +66,7 @@ describe('Flagd flag structure', () => {
expect(ff.state).toBe('ENABLED'); expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('pi2'); expect(ff.defaultVariant).toBe('pi2');
expect(ff.targeting).toBe(''); expect(ff.targeting).toBe('');
expect(ff.variants.get('pi2')).toStrictEqual({value: 3.14, accuracy: 2}); expect(ff.variants.get('pi2')).toStrictEqual({ value: 3.14, accuracy: 2 });
expect(ff.variants.get('pi5')).toStrictEqual({value: 3.14159, accuracy: 5}); expect(ff.variants.get('pi5')).toStrictEqual({ value: 3.14159, accuracy: 5 });
}); });
}); });

View File

@ -1,10 +1,10 @@
import {FlagValue} from '@openfeature/core'; import { FlagValue } from '@openfeature/core';
/** /**
* Flagd flag configuration structure mapping to schema definition. * Flagd flag configuration structure mapping to schema definition.
*/ */
export interface Flag { export interface Flag {
state: "ENABLED" | "DISABLED"; state: 'ENABLED' | 'DISABLED';
defaultVariant: string; defaultVariant: string;
variants: { [key: string]: FlagValue }; variants: { [key: string]: FlagValue };
targeting?: string; targeting?: string;

View File

@ -1,5 +1,5 @@
import {FlagdCore} from './flagd-core'; import { FlagdCore } from './flagd-core';
import {GeneralError, StandardResolutionReasons, TypeMismatchError} from '@openfeature/core'; import { GeneralError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core';
describe('flagd-core resolving', () => { describe('flagd-core resolving', () => {
const flagCfg = `{"flags":{"myBoolFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"key1":"val1","key2":"val2"},"defaultVariant":"key1"},"myFloatFlag":{"state":"ENABLED","variants":{"one":1.23,"two":2.34},"defaultVariant":"one"},"myIntFlag":{"state":"ENABLED","variants":{"one":1,"two":2},"defaultVariant":"one"},"myObjectFlag":{"state":"ENABLED","variants":{"object1":{"key":"val"},"object2":{"key":true}},"defaultVariant":"object1"},"fibAlgo":{"variants":{"recursive":"recursive","memo":"memo","loop":"loop","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailWithFaas"},"binet",null]}},"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",{"in":["Chrome",{"var":"userAgent"}]},"third",null]}}},"$evaluators":{"emailWithFaas":{"in":["@faas.com",{"var":["email"]}]}}}`; const flagCfg = `{"flags":{"myBoolFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"key1":"val1","key2":"val2"},"defaultVariant":"key1"},"myFloatFlag":{"state":"ENABLED","variants":{"one":1.23,"two":2.34},"defaultVariant":"one"},"myIntFlag":{"state":"ENABLED","variants":{"one":1,"two":2},"defaultVariant":"one"},"myObjectFlag":{"state":"ENABLED","variants":{"object1":{"key":"val"},"object2":{"key":true}},"defaultVariant":"object1"},"fibAlgo":{"variants":{"recursive":"recursive","memo":"memo","loop":"loop","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailWithFaas"},"binet",null]}},"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",{"in":["Chrome",{"var":"userAgent"}]},"third",null]}}},"$evaluators":{"emailWithFaas":{"in":["@faas.com",{"var":["email"]}]}}}`;
@ -14,63 +14,61 @@ describe('flagd-core resolving', () => {
const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console); const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console);
expect(resolved.value).toBeTruthy(); expect(resolved.value).toBeTruthy();
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("on") expect(resolved.variant).toBe('on');
}); });
it('should resolve string flag', () => { it('should resolve string flag', () => {
const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}, console); const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}, console);
expect(resolved.value).toBe('val1'); expect(resolved.value).toBe('val1');
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("key1") expect(resolved.variant).toBe('key1');
}); });
it('should resolve number flag', () => { it('should resolve number flag', () => {
const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}, console); const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}, console);
expect(resolved.value).toBe(1.23); expect(resolved.value).toBe(1.23);
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("one") expect(resolved.variant).toBe('one');
}); });
it('should resolve object flag', () => { it('should resolve object flag', () => {
const resolved = core.resolveObjectEvaluation('myObjectFlag', {key: true}, {}, console); const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}, console);
expect(resolved.value).toStrictEqual({key: 'val'}); expect(resolved.value).toStrictEqual({ key: 'val' });
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC); expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("object1") expect(resolved.variant).toBe('object1');
}); });
}); });
describe('flagd-core targeting evaluations', () => { describe('flagd-core targeting evaluations', () => {
const targetingFlag =
const targetingFlag = '{"flags":{"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",null]}},"shortCircuit":{"variants":{"true":true,"false":false},"defaultVariant":"false","state":"ENABLED","targeting":{"==":[{"var":"favoriteNumber"},1]}}}}'; '{"flags":{"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",null]}},"shortCircuit":{"variants":{"true":true,"false":false},"defaultVariant":"false","state":"ENABLED","targeting":{"==":[{"var":"favoriteNumber"},1]}}}}';
let core: FlagdCore; let core: FlagdCore;
beforeAll(() => { beforeAll(() => {
core = new FlagdCore(); core = new FlagdCore();
core.setConfigurations(targetingFlag) core.setConfigurations(targetingFlag);
}); });
it('should resolve for correct inputs', () => { it('should resolve for correct inputs', () => {
const resolved = core.resolveStringEvaluation("targetedFlag", "none", {email: "admin@openfeature.dev"}, console); const resolved = core.resolveStringEvaluation('targetedFlag', 'none', { email: 'admin@openfeature.dev' }, console);
expect(resolved.value).toBe("BBB") expect(resolved.value).toBe('BBB');
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH) expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe("second") expect(resolved.variant).toBe('second');
}); });
it('should fallback to default - missing targeting context data', () => { it('should fallback to default - missing targeting context data', () => {
const resolved = core.resolveStringEvaluation("targetedFlag", "none", {}, console); const resolved = core.resolveStringEvaluation('targetedFlag', 'none', {}, console);
expect(resolved.value).toBe("AAA") expect(resolved.value).toBe('AAA');
expect(resolved.reason).toBe(StandardResolutionReasons.DEFAULT) expect(resolved.reason).toBe(StandardResolutionReasons.DEFAULT);
expect(resolved.variant).toBe("first") expect(resolved.variant).toBe('first');
}); });
it('should handle short circuit fallbacks', () => { it('should handle short circuit fallbacks', () => {
const resolved = core.resolveBooleanEvaluation("shortCircuit", false, {favoriteNumber: 1}, console); const resolved = core.resolveBooleanEvaluation('shortCircuit', false, { favoriteNumber: 1 }, console);
expect(resolved.value).toBe(true) expect(resolved.value).toBe(true);
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH) expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe("true") expect(resolved.variant).toBe('true');
}); });
}); });
describe('flagd-core validations', () => { describe('flagd-core validations', () => {
@ -85,25 +83,22 @@ describe('flagd-core validations', () => {
}); });
it('should validate flag type - eval int as boolean', () => { it('should validate flag type - eval int as boolean', () => {
expect(() => core.resolveBooleanEvaluation('myIntFlag', true, {}, console)) expect(() => core.resolveBooleanEvaluation('myIntFlag', true, {}, console)).toThrow(GeneralError);
.toThrow(GeneralError)
}); });
it('should validate flag status', () => { it('should validate flag status', () => {
const evaluation = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console); const evaluation = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console);
expect(evaluation).toBeTruthy() expect(evaluation).toBeTruthy();
expect(evaluation.value).toBe(false) expect(evaluation.value).toBe(false);
expect(evaluation.reason).toBe(StandardResolutionReasons.DISABLED) expect(evaluation.reason).toBe(StandardResolutionReasons.DISABLED);
}); });
it('should validate variant', () => { it('should validate variant', () => {
expect(() => core.resolveStringEvaluation('myStringFlag', 'hello', {}, console)) expect(() => core.resolveStringEvaluation('myStringFlag', 'hello', {}, console)).toThrow(TypeMismatchError);
.toThrow(TypeMismatchError)
}); });
it('should validate variant existence', () => { it('should validate variant existence', () => {
expect(() => core.resolveNumberEvaluation('myIntFlag', 100, {}, console)) expect(() => core.resolveNumberEvaluation('myIntFlag', 100, {}, console)).toThrow(GeneralError);
.toThrow(GeneralError)
}); });
}); });

View File

@ -1,4 +1,4 @@
import {MemoryStorage, Storage} from './storage'; import { MemoryStorage, Storage } from './storage';
import { import {
EvaluationContext, EvaluationContext,
FlagNotFoundError, FlagNotFoundError,
@ -9,8 +9,8 @@ import {
StandardResolutionReasons, StandardResolutionReasons,
TypeMismatchError, TypeMismatchError,
} from '@openfeature/core'; } from '@openfeature/core';
import {Targeting} from "./targeting/targeting"; import { Targeting } from './targeting/targeting';
import {Logger} from "@openfeature/server-sdk"; import { Logger } from '@openfeature/server-sdk';
/** /**
* Expose flag configuration setter and flag resolving methods. * Expose flag configuration setter and flag resolving methods.
@ -76,7 +76,6 @@ export class FlagdCore {
logger: Logger, logger: Logger,
type: string, type: string,
): ResolutionDetails<T> { ): ResolutionDetails<T> {
// flag exist check // flag exist check
const flag = this._storage.getFlag(flagKey); const flag = this._storage.getFlag(flagKey);
if (!flag) { if (!flag) {
@ -88,7 +87,7 @@ export class FlagdCore {
return { return {
value: defaultValue, value: defaultValue,
reason: StandardResolutionReasons.DISABLED, reason: StandardResolutionReasons.DISABLED,
} };
} }
let variant; let variant;
@ -117,16 +116,18 @@ export class FlagdCore {
} }
if (typeof variant !== 'string') { if (typeof variant !== 'string') {
throw new TypeMismatchError('Variant must be a string, but found ' + typeof variant) throw new TypeMismatchError('Variant must be a string, but found ' + typeof variant);
} }
const resolvedVariant = flag.variants.get(variant) const resolvedVariant = flag.variants.get(variant);
if (!resolvedVariant) { if (!resolvedVariant) {
throw new GeneralError(`Variant ${variant} not found in flag with key ${flagKey}`); throw new GeneralError(`Variant ${variant} not found in flag with key ${flagKey}`);
} }
if (typeof resolvedVariant !== type) { if (typeof resolvedVariant !== type) {
throw new TypeMismatchError(`Evaluated type of the flag ${flagKey} does not match. Expected ${type}, got ${typeof resolvedVariant}`); throw new TypeMismatchError(
`Evaluated type of the flag ${flagKey} does not match. Expected ${type}, got ${typeof resolvedVariant}`,
);
} }
return { return {

View File

@ -57,6 +57,6 @@ describe('Flag configurations', () => {
const fibAlgo = flags.get('fibAlgo'); const fibAlgo = flags.get('fibAlgo');
expect(fibAlgo).toBeTruthy(); expect(fibAlgo).toBeTruthy();
expect(fibAlgo?.targeting).toStrictEqual({"if": [{"in": ["@faas.com", {"var": ["email"]}]}, "binet", null]}); expect(fibAlgo?.targeting).toStrictEqual({ if: [{ in: ['@faas.com', { var: ['email'] }] }, 'binet', null] });
}); });
}); });

View File

@ -1,5 +1,5 @@
import Ajv from 'ajv'; import Ajv from 'ajv';
import {FeatureFlag, Flag} from './feature-flag'; import { FeatureFlag, Flag } from './feature-flag';
import mydata from '../../flagd-schemas/json/flagd-definitions.json'; import mydata from '../../flagd-schemas/json/flagd-definitions.json';
const ajv = new Ajv(); const ajv = new Ajv();

View File

@ -1,5 +1,5 @@
import {FeatureFlag} from './feature-flag'; import { FeatureFlag } from './feature-flag';
import {parse} from './parser'; import { parse } from './parser';
/** /**
* The simple contract of the storage layer. * The simple contract of the storage layer.
@ -28,7 +28,7 @@ export class MemoryStorage implements Storage {
try { try {
this._flags = parse(cfg); this._flags = parse(cfg);
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
} }
} }

View File

@ -1,4 +1,4 @@
export const flagdPropertyKey = "$flagd"; export const flagdPropertyKey = '$flagd';
export const flagKeyPropertyKey = "flagKey"; export const flagKeyPropertyKey = 'flagKey';
export const timestampPropertyKey = "timestamp"; export const timestampPropertyKey = 'timestamp';
export const targetingPropertyKey = "targetingKey"; export const targetingPropertyKey = 'targetingKey';

View File

@ -1,7 +1,7 @@
import {flagdPropertyKey, flagKeyPropertyKey, targetingPropertyKey} from "./common"; import { flagdPropertyKey, flagKeyPropertyKey, targetingPropertyKey } from './common';
import MurmurHash3 from "imurmurhash"; import MurmurHash3 from 'imurmurhash';
export const fractionalRule = "fractional"; export const fractionalRule = 'fractional';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function fractional(data: unknown, context: Record<any, any>): string | null { export function fractional(data: unknown, context: Record<any, any>): string | null {
@ -11,7 +11,7 @@ export function fractional(data: unknown, context: Record<any, any>): string | n
const args = Array.from(data); const args = Array.from(data);
if (args.length < 2) { if (args.length < 2) {
console.error('Invalid targeting rule. Require at least two buckets.') console.error('Invalid targeting rule. Require at least two buckets.');
return null; return null;
} }
@ -23,26 +23,26 @@ export function fractional(data: unknown, context: Record<any, any>): string | n
let bucketBy: string; let bucketBy: string;
let buckets: unknown[]; let buckets: unknown[];
if (typeof args[0] == "string") { if (typeof args[0] == 'string') {
bucketBy = args[0]; bucketBy = args[0];
buckets = args.slice(1, args.length) buckets = args.slice(1, args.length);
} else { } else {
bucketBy = context[targetingPropertyKey]; bucketBy = context[targetingPropertyKey];
if (!bucketBy) { if (!bucketBy) {
console.error('Missing targetingKey property') console.error('Missing targetingKey property');
return null; return null;
} }
buckets = args; buckets = args;
} }
let bucketingList let bucketingList;
try { try {
bucketingList = toBucketingList(buckets) bucketingList = toBucketingList(buckets);
} catch (e) { } catch (e) {
console.error('Error parsing targeting rule', e) console.error('Error parsing targeting rule', e);
return null return null;
} }
const hashKey = flagdProperties[flagKeyPropertyKey] + bucketBy; const hashKey = flagdProperties[flagKeyPropertyKey] + bucketBy;
@ -57,42 +57,42 @@ export function fractional(data: unknown, context: Record<any, any>): string | n
sum += bucketEntry.fraction; sum += bucketEntry.fraction;
if (sum >= bucket) { if (sum >= bucket) {
return bucketEntry.variant return bucketEntry.variant;
} }
} }
return null; return null;
} }
function toBucketingList(from: unknown[]): { variant: string, fraction: number }[] { function toBucketingList(from: unknown[]): { variant: string; fraction: number }[] {
// extract bucketing options // extract bucketing options
const bucketingArray: { variant: string, fraction: number }[] = []; const bucketingArray: { variant: string; fraction: number }[] = [];
let bucketSum = 0; let bucketSum = 0;
for (let i = 0; i < from.length; i++) { for (let i = 0; i < from.length; i++) {
const entry = from[i] const entry = from[i];
if (!Array.isArray(entry)) { if (!Array.isArray(entry)) {
throw new Error("Invalid bucket entries"); throw new Error('Invalid bucket entries');
} }
if (entry.length != 2) { if (entry.length != 2) {
throw new Error("Invalid bucketing entry. Require two values - variant and percentage"); throw new Error('Invalid bucketing entry. Require two values - variant and percentage');
} }
if (typeof entry[0] !== 'string') { if (typeof entry[0] !== 'string') {
throw new Error("Bucketing require variant to be present in string format"); throw new Error('Bucketing require variant to be present in string format');
} }
if (typeof entry[1] !== 'number') { if (typeof entry[1] !== 'number') {
throw new Error("Bucketing require bucketing percentage to be present"); throw new Error('Bucketing require bucketing percentage to be present');
} }
bucketingArray.push({fraction: entry[1], variant: entry[0]}); bucketingArray.push({ fraction: entry[1], variant: entry[0] });
bucketSum += entry[1]; bucketSum += entry[1];
} }
if (bucketSum != 100) { if (bucketSum != 100) {
throw new Error("Bucketing sum must add up to 100"); throw new Error('Bucketing sum must add up to 100');
} }
return bucketingArray; return bucketingArray;

View File

@ -1,4 +1,4 @@
import {compare, parse} from 'semver' import { compare, parse } from 'semver';
export const semVerRule = 'sem_ver'; export const semVerRule = 'sem_ver';
@ -7,7 +7,7 @@ export function semVer(data: unknown): boolean {
return false; return false;
} }
const args = Array.from(data) const args = Array.from(data);
if (args.length != 3) { if (args.length != 3) {
return false; return false;
@ -42,5 +42,5 @@ export function semVer(data: unknown): boolean {
return semVer1.minor == semVer2.minor; return semVer1.minor == semVer2.minor;
} }
return false return false;
} }

View File

@ -1,12 +1,12 @@
export const startsWithRule = 'starts_with' export const startsWithRule = 'starts_with';
export const endsWithRule = 'ends_with' export const endsWithRule = 'ends_with';
export function startsWithHandler(data: unknown) { export function startsWithHandler(data: unknown) {
return compare(startsWithRule, data) return compare(startsWithRule, data);
} }
export function endsWithHandler(data: unknown) { export function endsWithHandler(data: unknown) {
return compare(endsWithRule, data) return compare(endsWithRule, data);
} }
function compare(method: string, data: unknown): boolean { function compare(method: string, data: unknown): boolean {
@ -17,19 +17,19 @@ function compare(method: string, data: unknown): boolean {
const params = Array.from(data); const params = Array.from(data);
if (params.length != 2) { if (params.length != 2) {
return false return false;
} }
if (typeof params[0] !== 'string' || typeof params[1] !== 'string') { if (typeof params[0] !== 'string' || typeof params[1] !== 'string') {
return false return false;
} }
switch (method) { switch (method) {
case startsWithRule: case startsWithRule:
return params[0].startsWith(params[1]) return params[0].startsWith(params[1]);
case endsWithRule: case endsWithRule:
return params[0].endsWith(params[1]) return params[0].endsWith(params[1]);
default: default:
return false return false;
} }
} }

View File

@ -1,215 +1,204 @@
import {Targeting} from "./targeting"; import { Targeting } from './targeting';
describe("Targeting rule evaluator", () => { describe('Targeting rule evaluator', () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
}) });
it('should inject flag key as a property', () => { it('should inject flag key as a property', () => {
const flagKey = "flagA" const flagKey = 'flagA';
const input = {'===': [{var: "$flagd.flagKey"}, flagKey]} const input = { '===': [{ var: '$flagd.flagKey' }, flagKey] };
expect(targeting.applyTargeting(flagKey, input, {})).toBeTruthy() expect(targeting.applyTargeting(flagKey, input, {})).toBeTruthy();
}); });
it('should inject current timestamp as a property', () => { it('should inject current timestamp as a property', () => {
const ts = Math.floor(Date.now() / 1000) const ts = Math.floor(Date.now() / 1000);
const input = {'>=': [{var: "$flagd.timestamp"}, ts]} const input = { '>=': [{ var: '$flagd.timestamp' }, ts] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should override injected properties if already present in context', () => { it('should override injected properties if already present in context', () => {
const flagKey = "flagA" const flagKey = 'flagA';
const input = {'===': [{var: "$flagd.flagKey"}, flagKey]} const input = { '===': [{ var: '$flagd.flagKey' }, flagKey] };
const ctx = { const ctx = {
$flagd: { $flagd: {
flagKey: "someOtherFlag" flagKey: 'someOtherFlag',
} },
} };
expect(targeting.applyTargeting(flagKey, input, ctx)).toBeTruthy() expect(targeting.applyTargeting(flagKey, input, ctx)).toBeTruthy();
}); });
}); });
describe("String comparison operator", () => { describe('String comparison operator', () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
})
it("should evaluate starts with calls", () => {
const input = {"starts_with": [{"var": "email"}, "admin"]}
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeTruthy()
}); });
it("should evaluate ends with calls", () => { it('should evaluate starts with calls', () => {
const input = {"ends_with": [{"var": "email"}, "abc.com"]} const input = { starts_with: [{ var: 'email' }, 'admin'] };
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeTruthy() expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeTruthy();
});
it('should evaluate ends with calls', () => {
const input = { ends_with: [{ var: 'email' }, 'abc.com'] };
expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeTruthy();
}); });
}); });
describe("String comparison operator should validate", () => { describe('String comparison operator should validate', () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
})
it("missing input", () => {
const input = {"starts_with": [{"var": "email"}]}
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeFalsy()
}); });
it("non string variable", () => { it('missing input', () => {
const input = {"starts_with": [{"var": "someNumber"}, "abc.com"]} const input = { starts_with: [{ var: 'email' }] };
expect(targeting.applyTargeting("flag", input, {someNumber: 123456})).toBeFalsy() expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeFalsy();
}); });
it("non string comparator", () => { it('non string variable', () => {
const input = {"starts_with": [{"var": "email"}, 123456]} const input = { starts_with: [{ var: 'someNumber' }, 'abc.com'] };
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeFalsy() expect(targeting.applyTargeting('flag', input, { someNumber: 123456 })).toBeFalsy();
});
it('non string comparator', () => {
const input = { starts_with: [{ var: 'email' }, 123456] };
expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeFalsy();
}); });
}); });
describe('Sem ver operator', () => {
describe("Sem ver operator", () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
}) });
it('should support equal operator', () => { it('should support equal operator', () => {
const input = {"sem_ver": ['v1.2.3', "=", "1.2.3"]} const input = { sem_ver: ['v1.2.3', '=', '1.2.3'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support neq operator', () => { it('should support neq operator', () => {
const input = {"sem_ver": ['v1.2.3', "!=", "1.2.4"]} const input = { sem_ver: ['v1.2.3', '!=', '1.2.4'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support lt operator', () => { it('should support lt operator', () => {
const input = {"sem_ver": ['v1.2.3', "<", "1.2.4"]} const input = { sem_ver: ['v1.2.3', '<', '1.2.4'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support lte operator', () => { it('should support lte operator', () => {
const input = {"sem_ver": ['v1.2.3', "<=", "1.2.3"]} const input = { sem_ver: ['v1.2.3', '<=', '1.2.3'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support gte operator', () => { it('should support gte operator', () => {
const input = {"sem_ver": ['v1.2.3', ">=", "1.2.3"]} const input = { sem_ver: ['v1.2.3', '>=', '1.2.3'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support gt operator', () => { it('should support gt operator', () => {
const input = {"sem_ver": ['v1.2.4', ">", "1.2.3"]} const input = { sem_ver: ['v1.2.4', '>', '1.2.3'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support major comparison operator', () => { it('should support major comparison operator', () => {
const input = {"sem_ver": ["v1.2.3", "^", "v1.0.0"]} const input = { sem_ver: ['v1.2.3', '^', 'v1.0.0'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should support minor comparison operator', () => { it('should support minor comparison operator', () => {
const input = {"sem_ver": ["v5.0.3", "~", "v5.0.8"]} const input = { sem_ver: ['v5.0.3', '~', 'v5.0.8'] };
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy() expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
}); });
it('should handle unknown operator', () => { it('should handle unknown operator', () => {
const input = {"sem_ver": ["v1.0.0", "-", "v1.0.0"]} const input = { sem_ver: ['v1.0.0', '-', 'v1.0.0'] };
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy() expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
}); });
it('should handle invalid inputs', () => { it('should handle invalid inputs', () => {
const input = {"sem_ver": ["myVersion_1", "=", "myVersion_1"]} const input = { sem_ver: ['myVersion_1', '=', 'myVersion_1'] };
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy() expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
}); });
it('should validate inputs', () => { it('should validate inputs', () => {
const input = {"sem_ver": ["myVersion_2", "+", "myVersion_1", "myVersion_1"]} const input = { sem_ver: ['myVersion_2', '+', 'myVersion_1', 'myVersion_1'] };
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy() expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
}); });
}) });
describe("fractional operator", () => { describe('fractional operator', () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
}) });
it("should evaluate valid rule", () => { it('should evaluate valid rule', () => {
const input = {
fractional: [{ var: 'key' }, ['red', 50], ['blue', 50]],
};
expect(targeting.applyTargeting('flagA', input, { key: 'bucketKeyA' })).toBe('red');
});
it('should evaluate valid rule', () => {
const input = {
fractional: [{ var: 'key' }, ['red', 50], ['blue', 50]],
};
expect(targeting.applyTargeting('flagA', input, { key: 'bucketKeyB' })).toBe('blue');
});
it('should evaluate valid rule with targeting key', () => {
const input = { const input = {
fractional: [ fractional: [
{"var": "key"}, ['red', 50],
["red", 50], ['blue', 50],
["blue", 50] ],
] };
}
expect(targeting.applyTargeting("flagA", input, {key: "bucketKeyA"})).toBe("red") expect(targeting.applyTargeting('flagA', input, { targetingKey: 'bucketKeyB' })).toBe('blue');
}) });
});
it("should evaluate valid rule", () => { describe('fractional operator should validate', () => {
const input = {
fractional: [
{"var": "key"},
["red", 50],
["blue", 50]
]
}
expect(targeting.applyTargeting("flagA", input, {key: "bucketKeyB"})).toBe("blue")
})
it("should evaluate valid rule with targeting key", () => {
const input = {
fractional: [
["red", 50],
["blue", 50]
]
}
expect(targeting.applyTargeting("flagA", input, {targetingKey: "bucketKeyB"})).toBe("blue")
})
})
describe("fractional operator should validate", () => {
let targeting: Targeting; let targeting: Targeting;
beforeAll(() => { beforeAll(() => {
targeting = new Targeting(); targeting = new Targeting();
}) });
it("bucket sum to be 100", () => { it('bucket sum to be 100', () => {
const input = { const input = {
fractional: [ fractional: [
["red", 55], ['red', 55],
["blue", 55] ['blue', 55],
] ],
} };
expect(targeting.applyTargeting("flagA", input, {targetingKey: "key"})).toBe(null) expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe(null);
}) });
it("buckets properties to have variant and fraction", () => { it('buckets properties to have variant and fraction', () => {
const input = { const input = {
fractional: [ fractional: [
["red", 50], ['red', 50],
[100, 50] [100, 50],
] ],
} };
expect(targeting.applyTargeting("flagA", input, {targetingKey: "key"})).toBe(null) expect(targeting.applyTargeting('flagA', input, { targetingKey: 'key' })).toBe(null);
}) });
}) });

View File

@ -1,8 +1,8 @@
import {LogicEngine,} from "json-logic-engine"; import { LogicEngine } from 'json-logic-engine';
import {endsWithHandler, endsWithRule, startsWithHandler, startsWithRule} from "./string-comp"; import { endsWithHandler, endsWithRule, startsWithHandler, startsWithRule } from './string-comp';
import {semVer, semVerRule} from "./sem-ver"; import { semVer, semVerRule } from './sem-ver';
import {fractional, fractionalRule} from "./fractional"; import { fractional, fractionalRule } from './fractional';
import {flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey} from "./common"; import { flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey } from './common';
export class Targeting { export class Targeting {
private readonly _logicEngine: LogicEngine; private readonly _logicEngine: LogicEngine;
@ -19,7 +19,7 @@ export class Targeting {
applyTargeting(flagKey: string, logic: unknown, data: object): unknown { applyTargeting(flagKey: string, logic: unknown, data: object): unknown {
if (Object.hasOwn(data, flagdPropertyKey)) { if (Object.hasOwn(data, flagdPropertyKey)) {
console.warn(`overwriting ${flagdPropertyKey} property in the context`) console.warn(`overwriting ${flagdPropertyKey} property in the context`);
} }
const ctxData = { const ctxData = {
@ -28,7 +28,7 @@ export class Targeting {
[flagKeyPropertyKey]: flagKey, [flagKeyPropertyKey]: flagKey,
[timestampPropertyKey]: Math.floor(Date.now() / 1000), [timestampPropertyKey]: Math.floor(Date.now() / 1000),
}, },
} };
return this._logicEngine.run(logic, ctxData); return this._logicEngine.run(logic, ctxData);
} }

View File

@ -53,7 +53,7 @@ export default async function (tree: Tree, schema: SchemaOptions) {
['spec.ts', 'ts'].forEach((suffix) => { ['spec.ts', 'ts'].forEach((suffix) => {
tree.rename( tree.rename(
joinPathFragments(projectLibDir, `${directory}-${fileName}.${suffix}`), joinPathFragments(projectLibDir, `${directory}-${fileName}.${suffix}`),
joinPathFragments(projectLibDir, `${libFileName}.${suffix}`) joinPathFragments(projectLibDir, `${libFileName}.${suffix}`),
); );
}); });
@ -180,14 +180,17 @@ function updatePackage(tree: Tree, projectRoot: string, schema: SchemaOptions) {
}; };
// use undefined or this defaults to "commonjs", which breaks things: https://github.com/open-feature/js-sdk-contrib/pull/596 // use undefined or this defaults to "commonjs", which breaks things: https://github.com/open-feature/js-sdk-contrib/pull/596
json.type = undefined json.type = undefined;
// client packages have a web-sdk dep, server js-sdk // client packages have a web-sdk dep, server js-sdk
json.peerDependencies = schema.category === 'client' ? { json.peerDependencies =
'@openfeature/web-sdk': '>=0.4.0', schema.category === 'client'
} : { ? {
'@openfeature/server-sdk': '^1.6.0', '@openfeature/web-sdk': '>=0.4.0',
} }
: {
'@openfeature/server-sdk': '^1.6.0',
};
return json; return json;
}); });