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/metrics';
export * from './lib/metrics';

View File

@ -1,16 +1,16 @@
// see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/
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 REQUESTS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_requests_total`;
export const SUCCESS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_success_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 const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`;
export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`;
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 FlagValue,
type Hook,
type HookContext
type HookContext,
} from '@openfeature/server-sdk';
import { Attributes, Counter, UpDownCounter, ValueType, metrics } from '@opentelemetry/api';
import {
@ -19,7 +19,7 @@ import {
REASON_ATTR,
REQUESTS_TOTAL_NAME,
SUCCESS_TOTAL_NAME,
VARIANT_ATTR
VARIANT_ATTR,
} from '../conventions';
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.
*
*
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/
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 evaluationErrorCounter: Counter<ErrorEvaluationAttributes>;
constructor(options?: MetricsHookOptions, private readonly logger?: Logger) {
constructor(
options?: MetricsHookOptions,
private readonly logger?: Logger,
) {
super(options, logger);
const meter = metrics.getMeter(METER_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',
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'enabled',
});
});
it('should use a stringified value as the variant value on the span event', () => {
const evaluationDetails: EvaluationDetails<boolean> = {
flagKey: hookContext.flagKey,
value: true,
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'true',
});
});
it('should set the value without extra quotes if value is already a string', () => {
const evaluationDetails: EvaluationDetails<string> = {
flagKey: hookContext.flagKey,
@ -84,14 +84,14 @@ describe('OpenTelemetry Hooks', () => {
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
'feature_flag.variant': 'already-string',
});
});
it('should not call addEvent because there is no active span', () => {
getActiveSpan.mockReturnValueOnce(undefined);
const evaluationDetails: EvaluationDetails<boolean> = {
@ -100,7 +100,7 @@ describe('OpenTelemetry Hooks', () => {
variant: 'enabled',
flagMetadata: {},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).not.toBeCalled();
});
@ -108,7 +108,6 @@ describe('OpenTelemetry Hooks', () => {
describe('attribute mapper configured', () => {
describe('no error in mapper', () => {
beforeEach(() => {
tracingHook = new TracingHook({
attributeMapper: (flagMetadata) => {
@ -132,9 +131,9 @@ describe('OpenTelemetry Hooks', () => {
metadata3: true,
},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'feature_flag.provider_name': 'testProvider',
@ -147,7 +146,6 @@ describe('OpenTelemetry Hooks', () => {
});
describe('error in mapper', () => {
beforeEach(() => {
tracingHook = new TracingHook({
attributeMapper: (_) => {
@ -167,9 +165,9 @@ describe('OpenTelemetry Hooks', () => {
metadata3: true,
},
};
tracingHook.after(hookContext, evaluationDetails);
expect(addEvent).toBeCalledWith('feature_flag', {
'feature_flag.key': 'testFlagKey',
'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.
*
*
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
*/
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 () => {
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 () => {
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 () => {
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 () => {
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) =>
this.events.emit(ProviderEvents.ConfigurationChanged, {
flagsChanged: projectConfig ? Object.keys(projectConfig.settings) : undefined,
})
}),
);
hooks.on('clientError', (message: string, error) => {
@ -90,7 +90,7 @@ export class ConfigCatProvider implements Provider {
async resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
if (!this._client) {
throw new GeneralError('Provider is not initialized');
@ -99,7 +99,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
transformContext(context),
);
const validatedValue = validateFlagType('boolean', value);
@ -112,7 +112,7 @@ export class ConfigCatProvider implements Provider {
public async resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
if (!this._client) {
throw new GeneralError('Provider is not initialized');
@ -121,7 +121,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
transformContext(context),
);
const validatedValue = validateFlagType('string', value);
@ -134,7 +134,7 @@ export class ConfigCatProvider implements Provider {
public async resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<number>> {
if (!this._client) {
throw new GeneralError('Provider is not initialized');
@ -143,7 +143,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync<SettingValue>(
flagKey,
undefined,
transformContext(context)
transformContext(context),
);
const validatedValue = validateFlagType('number', value);
@ -156,7 +156,7 @@ export class ConfigCatProvider implements Provider {
public async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<U>> {
if (!this._client) {
throw new GeneralError('Provider is not initialized');
@ -165,7 +165,7 @@ export class ConfigCatProvider implements Provider {
const { value, ...evaluationData } = await this._client.getValueDetailsAsync(
flagKey,
undefined,
transformContext(context)
transformContext(context),
);
if (typeof value === 'undefined') {
@ -197,7 +197,7 @@ export class ConfigCatProvider implements Provider {
function toResolutionDetails<U extends JsonValue>(
value: U,
data: Omit<IEvaluationDetails, 'value'>,
reason?: ResolutionReason
reason?: ResolutionReason,
): ResolutionDetails<U> {
const matchedRule = Boolean(data.matchedEvaluationRule || data.matchedEvaluationPercentageRule);
const evaluatedReason = matchedRule ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC;

View File

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

View File

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

View File

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

View File

@ -6,14 +6,16 @@ const FLAGD_WEB_NAME = 'flagd-web';
// register the flagd provider before the tests.
console.log('Setting flagd web provider...');
OpenFeature.setProvider(new FlagdWebProvider({
host: 'localhost',
port: 8013,
tls: false,
maxRetries: -1,
}));
OpenFeature.setProvider(
new FlagdWebProvider({
host: 'localhost',
port: 8013,
tls: false,
maxRetries: -1,
}),
);
assert(
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!');

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';
// load the feature file.
@ -8,7 +17,7 @@ const feature = loadFeature('features/evaluation.feature');
const client = OpenFeature.getClient();
const givenAnOpenfeatureClientIsRegistered = (
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void
given: (stepMatcher: string, stepDefinitionCallback: () => void) => void,
) => {
given('a provider is registered', () => undefined);
};
@ -33,7 +42,7 @@ defineFeature(feature, (test) => {
/^a boolean flag with key "(.*)" is evaluated with default value "(.*)"$/,
(key: string, defaultValue: string) => {
value = client.getBooleanValue(key, defaultValue === 'true');
}
},
);
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 "(.*)"$/,
(key: string, defaultValue: string) => {
value = client.getStringValue(key, defaultValue);
}
},
);
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+)$/,
(key: string, defaultValue: number) => {
value = client.getNumberValue(key, defaultValue);
}
},
);
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*)$/,
(key: string, defaultValue: string) => {
value = client.getNumberValue(key, Number.parseFloat(defaultValue));
}
},
);
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[field2]).toEqual(strVal);
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 "(.*)"$/,
(key: string, defaultValue: string) => {
details = client.getBooleanDetails(key, defaultValue === 'true');
}
},
);
then(
@ -129,7 +138,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(expectedValue === 'true');
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
}
},
);
});
@ -142,7 +151,7 @@ defineFeature(feature, (test) => {
/^a string flag with key "(.*)" is evaluated with details and default value "(.*)"$/,
(key: string, defaultValue: string) => {
details = client.getStringDetails(key, defaultValue);
}
},
);
then(
@ -151,7 +160,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(expectedValue);
expect(details.variant).toEqual(expectedVariant);
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+)$/,
(key: string, defaultValue: string) => {
details = client.getNumberDetails(key, Number.parseInt(defaultValue));
}
},
);
then(
@ -173,7 +182,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(Number.parseInt(expectedValue));
expect(details.variant).toEqual(expectedVariant);
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*)$/,
(key: string, defaultValue: string) => {
details = client.getNumberDetails(key, Number.parseFloat(defaultValue));
}
},
);
then(
@ -195,7 +204,7 @@ defineFeature(feature, (test) => {
expect(details.value).toEqual(Number.parseFloat(expectedValue));
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
}
},
);
});
@ -216,7 +225,7 @@ defineFeature(feature, (test) => {
expect(jsonObject[field1]).toEqual(boolValue === 'true');
expect(jsonObject[field2]).toEqual(stringValue);
expect(jsonObject[field3]).toEqual(Number.parseInt(intValue));
}
},
);
and(
@ -224,7 +233,7 @@ defineFeature(feature, (test) => {
(expectedVariant: string, expectedReason: string) => {
expect(details.variant).toEqual(expectedVariant);
expect(details.reason).toEqual(expectedReason);
}
},
);
});
@ -245,7 +254,7 @@ defineFeature(feature, (test) => {
stringValue1: string,
stringValue2: string,
intValue: string,
boolValue: string
boolValue: string,
) => {
context[stringField1] = stringValue1;
context[stringField2] = stringValue2;
@ -253,7 +262,7 @@ defineFeature(feature, (test) => {
context[boolField] = boolValue === 'true';
await OpenFeature.setContext(context);
}
},
);
and(/^a flag with key "(.*)" is evaluated with default value "(.*)"$/, (key: string, defaultValue: string) => {
@ -285,7 +294,7 @@ defineFeature(feature, (test) => {
flagKey = key;
fallbackValue = defaultValue;
details = client.getStringDetails(flagKey, defaultValue);
}
},
);
then(/^the default string value should be returned$/, () => {
@ -297,7 +306,7 @@ defineFeature(feature, (test) => {
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
}
},
);
});
@ -314,7 +323,7 @@ defineFeature(feature, (test) => {
flagKey = key;
fallbackValue = Number.parseInt(defaultValue);
details = client.getNumberDetails(flagKey, Number.parseInt(defaultValue));
}
},
);
then(/^the default integer value should be returned$/, () => {
@ -326,7 +335,7 @@ defineFeature(feature, (test) => {
(errorCode: string) => {
expect(details.reason).toEqual(StandardResolutionReasons.ERROR);
expect(details.errorCode).toEqual(errorCode);
}
},
);
});
});

View File

@ -50,7 +50,7 @@ export class FlagdWebProvider implements Provider {
options: FlagdProviderOptions,
logger?: Logger,
promiseClient?: PromiseClient<typeof Service>,
callbackClient?: CallbackClient<typeof Service>
callbackClient?: CallbackClient<typeof Service>,
) {
const { host, port, tls, maxRetries, maxDelay, pathPrefix } = getOptions(options);
const transport = createConnectTransport({
@ -151,7 +151,7 @@ export class FlagdWebProvider implements Provider {
this._logger?.warn(`${FlagdWebProvider.name}: max retries reached`);
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).
*
*
* @default ""
*/
pathPrefix: string;
@ -52,7 +52,7 @@ export function getOptions(options: FlagdProviderOptions): Options {
tls: true,
maxRetries: 0,
maxDelay: DEFAULT_MAX_DELAY,
pathPrefix: ""
pathPrefix: '',
},
...options,
};

View File

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

View File

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

View File

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

View File

@ -11,19 +11,19 @@ import {
ResolutionDetails,
StandardResolutionReasons,
TypeMismatchError,
} from "@openfeature/web-sdk";
} from '@openfeature/web-sdk';
import {
FlagState,
GoFeatureFlagAllFlagRequest,
GOFeatureFlagAllFlagsResponse,
GoFeatureFlagWebProviderOptions,
GOFeatureFlagWebsocketResponse,
} from "./model";
import {transformContext} from "./context-transformer";
import {FetchError} from "./fetch-error";
} from './model';
import { transformContext } from './context-transformer';
import { FetchError } from './fetch-error';
export class GoFeatureFlagWebProvider implements Provider {
private readonly _websocketPath = "ws/v1/flag/change"
private readonly _websocketPath = 'ws/v1/flag/change';
metadata = {
name: GoFeatureFlagWebProvider.name,
@ -73,7 +73,9 @@ export class GoFeatureFlagWebProvider implements Provider {
this._logger?.debug(`${GoFeatureFlagWebProvider.name}: go-feature-flag provider initialized`);
})
.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.handleFetchErrors(error);
@ -81,7 +83,7 @@ export class GoFeatureFlagWebProvider implements Provider {
// and we launch the retry to fetch the data.
this.retryFetchAll(context);
this.reconnectWebsocket();
})
});
}
/**
@ -90,16 +92,17 @@ export class GoFeatureFlagWebProvider implements Provider {
*/
async connectWebsocket(): Promise<void> {
const wsURL = new URL(this._endpoint);
wsURL.pathname =
wsURL.pathname.endsWith('/') ? wsURL.pathname + this._websocketPath : wsURL.pathname + '/' + this._websocketPath;
wsURL.pathname = wsURL.pathname.endsWith('/')
? wsURL.pathname + this._websocketPath
: wsURL.pathname + '/' + this._websocketPath;
wsURL.protocol = wsURL.protocol === 'https:' ? 'wss' : 'ws';
// adding API Key if GO Feature Flag use api keys.
if(this._apiKey){
if (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);
await this.waitWebsocketFinalStatus(this._websocket);
@ -107,12 +110,12 @@ export class GoFeatureFlagWebProvider implements Provider {
this._websocket.onopen = (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`);
const t: GOFeatureFlagWebsocketResponse = JSON.parse(data);
const flagsChanged = this.extractFlagNamesFromWebsocket(t);
await this.retryFetchAll(OpenFeature.getContext(), flagsChanged);
}
};
this._websocket.onclose = async () => {
this._logger?.warn(`${GoFeatureFlagWebProvider.name}: Websocket closed, trying to reconnect`);
await this.reconnectWebsocket();
@ -167,46 +170,48 @@ export class GoFeatureFlagWebProvider implements Provider {
let attempt = 0;
while (attempt < this._maxRetries) {
attempt++;
await this.connectWebsocket()
await this.connectWebsocket();
if (this._websocket !== undefined && this._websocket.readyState === WebSocket.OPEN) {
return
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
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, {
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> {
this._websocket?.close(1000, "Closing GO Feature Flag provider");
this._websocket?.close(1000, 'Closing GO Feature Flag provider');
return Promise.resolve();
}
async onContextChange(_: EvaluationContext, newContext: EvaluationContext): Promise<void> {
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);
this.events.emit(ProviderEvents.Ready, {message: ''});
this.events.emit(ProviderEvents.Ready, { message: '' });
}
resolveNumberEvaluation(flagKey: string): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number')
return this.evaluate(flagKey, 'number');
}
resolveObjectEvaluation<T extends FlagValue>(flagKey: string): ResolutionDetails<T> {
return this.evaluate(flagKey, 'object')
return this.evaluate(flagKey, 'object');
}
resolveStringEvaluation(flagKey: string): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string')
return this.evaluate(flagKey, 'string');
}
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> {
@ -236,13 +241,15 @@ export class GoFeatureFlagWebProvider implements Provider {
try {
await this.fetchAll(ctx, flagsChanged);
this._status = ProviderStatus.READY;
return
return;
} catch (err) {
this._status = ProviderStatus.ERROR;
this.handleFetchErrors(err)
this.handleFetchErrors(err);
await new Promise((resolve) => setTimeout(resolve, delay));
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[] = []) {
const endpointURL = new URL(this._endpoint);
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({
'Content-Type': 'application/json',
Accept: 'application/json',
});
if(this._apiKey){
if (this._apiKey) {
headers.set('Authorization', `Bearer ${this._apiKey}`);
}
const init: RequestInit = {
method: "POST",
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(request)
body: JSON.stringify(request),
};
const response = await fetch(endpointURL.toString(), init);
if(!response?.ok){
if (!response?.ok) {
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
let flags = {};
Object.keys(data.flags).forEach(currentValue => {
Object.keys(data.flags).forEach((currentValue) => {
const resolved: FlagState<FlagValue> = data.flags[currentValue];
const resolutionDetails: ResolutionDetails<FlagValue> = {
value: resolved.value,
variant: resolved.variationType,
errorCode: resolved.errorCode,
flagMetadata: resolved.metadata,
reason: resolved.reason
reason: resolved.reason,
};
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;
if (hasFlagsLoaded) {
this.events.emit(ProviderEvents.ConfigurationChanged, {
@ -323,9 +332,13 @@ export class GoFeatureFlagWebProvider implements Provider {
message: error.message,
});
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) {
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 {
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
@ -20,7 +20,6 @@ export interface GoFeatureFlagAllFlagRequest {
evaluationContext: GoFeatureFlagEvaluationContext;
}
/**
* GoFeatureFlagProviderOptions is the object containing all the provider options
* when initializing the open-feature provider.
@ -52,7 +51,6 @@ export interface GoFeatureFlagWebProviderOptions {
maxRetries?: number;
}
/**
* 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.
*/
export interface GOFeatureFlagAllFlagsResponse {
valid: boolean
flags: Record<string, FlagState<FlagValue>>
valid: boolean;
flags: Record<string, FlagState<FlagValue>>;
}
/**
* Format of the websocket event we can receive.
*/
export interface GOFeatureFlagWebsocketResponse {
deleted?: { [key: string]: any }
added?: { [key: string]: any }
updated?: { [key: string]: any }
deleted?: { [key: string]: any };
added?: { [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.
* @returns {GoFeatureFlagUser} the user against who we will evaluate the flag.
*/
export function transformContext(
context: EvaluationContext
): GoFeatureFlagUser {
export function transformContext(context: EvaluationContext): GoFeatureFlagUser {
const { targetingKey, ...attributes } = context;
// 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
let anonymous = false;
if (
attributes !== undefined &&
attributes !== null &&
'anonymous' in attributes
) {
if (attributes !== undefined && attributes !== null && 'anonymous' in attributes) {
if (typeof attributes['anonymous'] === 'boolean') {
anonymous = attributes['anonymous'];
}

View File

@ -3,21 +3,16 @@ import {
FlagValue,
Hook,
HookContext,
HookHints, Logger, StandardResolutionReasons,
HookHints,
Logger,
StandardResolutionReasons,
} from '@openfeature/server-sdk';
import {
DataCollectorHookOptions,
DataCollectorRequest,
DataCollectorResponse,
FeatureEvent,
} from './model';
import {copy} from 'copy-anything';
import { DataCollectorHookOptions, DataCollectorRequest, DataCollectorResponse, FeatureEvent } from './model';
import { copy } from 'copy-anything';
import axios from 'axios';
const defaultTargetingKey = 'undefined-targetingKey';
export class GoFeatureFlagDataCollectorHook implements Hook {
// bgSchedulerId contains the id of the setInterval that is running.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -54,16 +49,15 @@ export class GoFeatureFlagDataCollectorHook implements Hook {
this.collectUnCachedEvaluation = options.collectUnCachedEvaluation;
}
init(){
this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval)
this.dataCollectorBuffer = []
init() {
this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval);
this.dataCollectorBuffer = [];
}
async close() {
clearInterval(this.bgScheduler);
// 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) || [];
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);
endpointURL.pathname = 'v1/data/collector';
@ -91,19 +85,14 @@ export class GoFeatureFlagDataCollectorHook implements Hook {
timeout: this.timeout,
});
} 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
this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend];
}
}
after(
hookContext: HookContext,
evaluationDetails: EvaluationDetails<FlagValue>,
hookHints?: HookHints
) {
if (!this.collectUnCachedEvaluation && evaluationDetails.reason !== StandardResolutionReasons.CACHED){
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>, hookHints?: HookHints) {
if (!this.collectUnCachedEvaluation && evaluationDetails.reason !== StandardResolutionReasons.CACHED) {
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
// to return a valid response.
export class ProxyNotReady extends OpenFeatureError {
code: ErrorCode
code: ErrorCode;
constructor(message: string, originalError: Error) {
super(`${message}: ${originalError}`)
Object.setPrototypeOf(this, ProxyNotReady.prototype)
this.code = ErrorCode.PROVIDER_NOT_READY
super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, ProxyNotReady.prototype);
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
// in the appropriate time.
export class ProxyTimeout extends OpenFeatureError {
code: ErrorCode
code: ErrorCode;
constructor(message: string, originalError: Error) {
super(`${message}: ${originalError}`)
Object.setPrototypeOf(this, ProxyTimeout.prototype)
this.code = ErrorCode.GENERAL
super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, ProxyTimeout.prototype);
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.
export class Unauthorized extends OpenFeatureError {
code: ErrorCode
code: ErrorCode;
constructor(message: string) {
super(message)
Object.setPrototypeOf(this, Unauthorized.prototype)
this.code = ErrorCode.GENERAL
super(message);
Object.setPrototypeOf(this, Unauthorized.prototype);
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.
export class UnknownError extends OpenFeatureError {
code: ErrorCode
code: ErrorCode;
constructor(message: string, originalError: Error | unknown) {
super(`${message}: ${originalError}`)
Object.setPrototypeOf(this, UnknownError.prototype)
this.code = ErrorCode.GENERAL
super(`${message}: ${originalError}`);
Object.setPrototypeOf(this, UnknownError.prototype);
this.code = ErrorCode.GENERAL;
}
}

View File

@ -1,17 +1,11 @@
/**
* @jest-environment node
*/
import {
Client,
ErrorCode,
OpenFeature,
ProviderStatus,
StandardResolutionReasons,
} from '@openfeature/server-sdk';
import { Client, ErrorCode, OpenFeature, ProviderStatus, StandardResolutionReasons } from '@openfeature/server-sdk';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import {GoFeatureFlagProvider} from './go-feature-flag-provider';
import {GoFeatureFlagProxyResponse} from './model';
import { GoFeatureFlagProvider } from './go-feature-flag-provider';
import { GoFeatureFlagProxyResponse } from './model';
import TestLogger from './test-logger';
describe('GoFeatureFlagProvider', () => {
@ -48,14 +42,14 @@ describe('GoFeatureFlagProvider', () => {
await OpenFeature.close();
axiosMock.reset();
axiosMock.resetHistory();
goff = new GoFeatureFlagProvider({endpoint});
goff = new GoFeatureFlagProvider({ endpoint });
OpenFeature.setProvider('test-provider', goff);
cli = OpenFeature.getClient('test-provider');
});
describe('common usecases and errors', () => {
it('should be an instance of GoFeatureFlagProvider', () => {
const goff = new GoFeatureFlagProvider({endpoint});
const goff = new GoFeatureFlagProvider({ endpoint });
expect(goff).toBeInstanceOf(GoFeatureFlagProvider);
});
it('should throw an error if proxy not ready', async () => {
@ -63,31 +57,31 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(404);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
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,
reason: StandardResolutionReasons.ERROR,
value: false,
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
it('should throw an error if the call timeout', async () => {
const flagName = 'random-flag';
const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).timeout();
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
errorCode: ErrorCode.GENERAL,
errorMessage: 'impossible to retrieve the random-flag on time: Error: timeout of 0ms exceeded',
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
value: false,
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -101,14 +95,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
errorCode: ErrorCode.PROVIDER_NOT_READY,
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
errorCode: ErrorCode.PROVIDER_NOT_READY,
flagKey: flagName,
reason: StandardResolutionReasons.UNKNOWN,
value: true,
variant: 'trueVariation',
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -121,14 +115,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
errorCode: 'NOT-AN-SDK-ERROR',
} as unknown as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
errorCode: ErrorCode.GENERAL,
flagKey: flagName,
reason: StandardResolutionReasons.UNKNOWN,
value: true,
variant: 'trueVariation',
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -138,14 +132,14 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).networkError();
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
errorCode: ErrorCode.GENERAL,
errorMessage: `unknown error while retrieving flag ${flagName} for user ${targetingKey}: Error: Network Error`,
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
value: false,
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -159,14 +153,14 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
errorCode: ErrorCode.FLAG_NOT_FOUND,
} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'sdk-default', {targetingKey})
const res = await cli.getStringDetails(flagName, 'sdk-default', { targetingKey });
const want = {
errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: `Flag ${flagName} was not found in your configuration`,
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
value: 'sdk-default',
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -175,14 +169,14 @@ describe('GoFeatureFlagProvider', () => {
const targetingKey = 'user-key';
const dns = `${endpoint}v1/feature/${flagName}/eval`;
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 = {
errorCode: ErrorCode.GENERAL,
errorMessage: 'invalid token used to contact GO Feature Flag relay proxy instance',
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
value: 'sdk-default',
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -199,18 +193,18 @@ describe('GoFeatureFlagProvider', () => {
trackEvents: true,
version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
value: true,
variant: 'trueVariation',
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
it('provider should start not ready', async () => {
const goff = new GoFeatureFlagProvider({endpoint});
const goff = new GoFeatureFlagProvider({ endpoint });
expect(goff.status).toEqual(ProviderStatus.NOT_READY);
});
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(dataCollectorEndpoint).reply(500, {});
const goff = new GoFeatureFlagProvider({
endpoint,
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 2000, // in milliseconds
}, testLogger)
const goff = new GoFeatureFlagProvider(
{
endpoint,
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 2000, // in milliseconds
},
testLogger,
);
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 = {
errorCode: ErrorCode.PROVIDER_NOT_READY,
errorMessage: 'Provider in a status that does not allow to serve flag: NOT_READY',
reason: StandardResolutionReasons.ERROR,
value: false
value: false,
};
expect(got).toEqual(want);
});
@ -253,14 +250,14 @@ describe('GoFeatureFlagProvider', () => {
value: 'true',
variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: 'Flag value random-flag had unexpected type string, expected boolean.',
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
value: false,
flagMetadata: {}
flagMetadata: {},
};
expect(res).toEqual(want);
});
@ -279,13 +276,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
value: true,
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -303,13 +300,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.SPLIT,
value: true,
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -326,7 +323,7 @@ describe('GoFeatureFlagProvider', () => {
trackEvents: true,
version: '1.0.0',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.DISABLED,
@ -347,11 +344,11 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getStringDetails(flagName, 'false', {targetingKey})
const res = await cli.getStringDetails(flagName, 'false', { targetingKey });
const want = {
flagKey: flagName,
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,
value: 'false',
flagMetadata: {},
@ -372,7 +369,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'default', {targetingKey})
const res = await cli.getStringDetails(flagName, 'default', { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
@ -396,7 +393,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'default', {targetingKey})
const res = await cli.getStringDetails(flagName, 'default', { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.SPLIT,
@ -420,7 +417,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<string>);
const res = await cli.getStringDetails(flagName, 'randomDefaultValue', {targetingKey})
const res = await cli.getStringDetails(flagName, 'randomDefaultValue', { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.DISABLED,
@ -441,7 +438,7 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey})
const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
@ -466,12 +463,12 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey})
const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
value: 14,
variant:'trueVariation',
variant: 'trueVariation',
flagMetadata: {},
};
expect(res).toEqual(want);
@ -490,12 +487,12 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey})
const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.SPLIT,
value: 14,
variant:'trueVariation',
variant: 'trueVariation',
flagMetadata: {},
};
expect(res).toEqual(want);
@ -514,7 +511,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<number>);
const res = await cli.getNumberDetails(flagName, 14, {targetingKey})
const res = await cli.getNumberDetails(flagName, 14, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.DISABLED,
@ -535,7 +532,7 @@ describe('GoFeatureFlagProvider', () => {
variationType: 'trueVariation',
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getObjectDetails(flagName, {}, {targetingKey})
const res = await cli.getObjectDetails(flagName, {}, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.ERROR,
@ -552,7 +549,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, {
value: {key: true},
value: { key: true },
variationType: 'trueVariation',
reason: StandardResolutionReasons.TARGETING_MATCH,
failed: false,
@ -560,13 +557,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
value: {key: true},
value: { key: true },
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -576,7 +573,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, {
value: {key: true},
value: { key: true },
variationType: 'trueVariation',
reason: StandardResolutionReasons.SPLIT,
failed: false,
@ -584,13 +581,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.SPLIT,
value: {key: true},
value: { key: true },
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -600,7 +597,7 @@ describe('GoFeatureFlagProvider', () => {
const dns = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns).reply(200, {
value: {key: 123},
value: { key: 123 },
variationType: 'defaultSdk',
reason: StandardResolutionReasons.DISABLED,
failed: false,
@ -608,11 +605,11 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.DISABLED,
value: {key: 'default'},
value: { key: 'default' },
flagMetadata: {},
};
expect(res).toEqual(want);
@ -631,13 +628,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
value: ['1', '2'],
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -655,14 +652,13 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, {key: 'default'}, {targetingKey})
const res = await cli.getObjectDetails(flagName, { key: 'default' }, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.SPLIT,
value: ['1', '2'],
flagMetadata: {},
variant: 'trueVariation'
variant: 'trueVariation',
};
expect(res).toEqual(want);
});
@ -680,7 +676,7 @@ describe('GoFeatureFlagProvider', () => {
version: '1.0.0',
} as GoFeatureFlagProxyResponse<object>);
const res = await cli.getObjectDetails(flagName, ['key', '124'], {targetingKey})
const res = await cli.getObjectDetails(flagName, ['key', '124'], { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.DISABLED,
@ -708,7 +704,7 @@ describe('GoFeatureFlagProvider', () => {
cacheable: true,
} as GoFeatureFlagProxyResponse<boolean>);
const res = await cli.getBooleanDetails(flagName, false, {targetingKey})
const res = await cli.getBooleanDetails(flagName, false, { targetingKey });
const want = {
flagKey: flagName,
reason: StandardResolutionReasons.TARGETING_MATCH,
@ -734,11 +730,11 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000,
flagCacheSize: 1,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache');
const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey});
const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey});
const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey });
const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(got1.reason).toEqual(StandardResolutionReasons.TARGETING_MATCH);
expect(got2.reason).toEqual(StandardResolutionReasons.CACHED);
expect(axiosMock.history['post'].length).toBe(1);
@ -754,11 +750,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint,
disableCache: true,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache');
const got1 = await cli.getBooleanDetails(flagName, false, {targetingKey});
const got2 = await cli.getBooleanDetails(flagName, false, {targetingKey});
const got1 = await cli.getBooleanDetails(flagName, false, { targetingKey });
const got2 = await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(got1).toEqual(got2);
expect(axiosMock.history['post'].length).toBe(2);
});
@ -775,12 +771,12 @@ describe('GoFeatureFlagProvider', () => {
endpoint,
flagCacheSize: 1,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName1, false, {targetingKey});
await cli.getBooleanDetails(flagName2, false, {targetingKey});
await cli.getBooleanDetails(flagName1, false, {targetingKey});
await cli.getBooleanDetails(flagName1, false, { targetingKey });
await cli.getBooleanDetails(flagName2, false, { targetingKey });
await cli.getBooleanDetails(flagName1, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(3);
});
@ -788,16 +784,16 @@ describe('GoFeatureFlagProvider', () => {
const flagName = 'random-flag';
const targetingKey = 'user-key';
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({
endpoint,
flagCacheSize: 1,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
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);
});
@ -805,18 +801,18 @@ describe('GoFeatureFlagProvider', () => {
const flagName = 'random-flag';
const targetingKey = 'user-key';
const dns1 = `${endpoint}v1/feature/${flagName}/eval`;
axiosMock.onPost(dns1).reply(200, {...validBoolResponse});
axiosMock.onPost(dns1).reply(200, { ...validBoolResponse });
const goff = new GoFeatureFlagProvider({
endpoint,
flagCacheSize: 1,
disableDataCollection: true,
flagCacheTTL: 200,
})
OpenFeature.setProvider('test-provider-cache',goff);
});
OpenFeature.setProvider('test-provider-cache', goff);
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 cli.getBooleanDetails(flagName, false, {targetingKey});
await cli.getBooleanDetails(flagName, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(2);
});
@ -832,11 +828,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint,
flagCacheSize: 1,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
const cli = OpenFeature.getClient('test-provider-cache');
await cli.getBooleanDetails(flagName1, false, {targetingKey});
await cli.getBooleanDetails(flagName2, false, {targetingKey});
await cli.getBooleanDetails(flagName1, false, { targetingKey });
await cli.getBooleanDetails(flagName2, false, { targetingKey });
expect(axiosMock.history['post'].length).toBe(2);
});
it('should not retrieve from the cache if context properties are different but same targeting key', async () => {
@ -848,11 +844,11 @@ describe('GoFeatureFlagProvider', () => {
endpoint,
flagCacheSize: 1,
disableDataCollection: true,
})
});
OpenFeature.setProvider('test-provider-cache', goff);
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: 'bar.foo@gofeatureflag.org'});
await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'foo.bar@gofeatureflag.org' });
await cli.getBooleanDetails(flagName1, false, { targetingKey, email: 'bar.foo@gofeatureflag.org' });
expect(axiosMock.history['post'].length).toBe(2);
});
});
@ -868,28 +864,31 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 1000, // in milliseconds
})
});
const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff);
const cli = OpenFeature.getClient(providerName);
await cli.getBooleanDetails(flagName, false, {targetingKey});
await cli.getBooleanDetails(flagName, false, {targetingKey});
await OpenFeature.close()
const collectorCalls = axiosMock.history['post'].filter(i => i.url === dataCollectorEndpoint);
await cli.getBooleanDetails(flagName, false, { targetingKey });
await cli.getBooleanDetails(flagName, false, { targetingKey });
await OpenFeature.close();
const collectorCalls = axiosMock.history['post'].filter((i) => i.url === dataCollectorEndpoint);
expect(collectorCalls.length).toBe(1);
const got = JSON.parse(collectorCalls[0].data);
expect(isNaN(got.events[0].creationDate)).toBe(false);
const want = {
events: [{
contextKind: 'user',
kind: 'feature',
creationDate: got.events[0].creationDate,
default: false,
key: 'random-flag',
value: true,
variation: 'trueVariation',
userKey: 'user-key'
}], meta: {provider: 'open-feature-js-sdk'}
events: [
{
contextKind: 'user',
kind: 'feature',
creationDate: got.events[0].creationDate,
default: false,
key: 'random-flag',
value: true,
variation: 'trueVariation',
userKey: 'user-key',
},
],
meta: { provider: 'open-feature-js-sdk' },
};
expect(want).toEqual(got);
});
@ -905,14 +904,14 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 100, // in milliseconds
})
});
const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff);
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));
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);
});
@ -927,20 +926,20 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 100, // in milliseconds
})
});
const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff);
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));
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);
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));
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);
});
@ -955,14 +954,14 @@ describe('GoFeatureFlagProvider', () => {
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 200, // in milliseconds
})
});
const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff);
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));
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);
});
@ -975,21 +974,26 @@ describe('GoFeatureFlagProvider', () => {
axiosMock.onPost(dns).reply(200, validBoolResponse);
axiosMock.onPost(dataCollectorEndpoint).reply(500, {});
const goff = new GoFeatureFlagProvider({
endpoint,
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 2000, // in milliseconds
}, testLogger)
const goff = new GoFeatureFlagProvider(
{
endpoint,
flagCacheTTL: 3000,
flagCacheSize: 100,
dataFlushInterval: 2000, // in milliseconds
},
testLogger,
);
const providerName = expect.getState().currentTestName || 'test';
OpenFeature.setProvider(providerName, goff);
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();
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,
} from '@openfeature/server-sdk';
import axios from 'axios';
import {transformContext} from './context-transformer';
import {ProxyNotReady} from './errors/proxyNotReady';
import {ProxyTimeout} from './errors/proxyTimeout';
import {UnknownError} from './errors/unknownError';
import {Unauthorized} from './errors/unauthorized';
import {LRUCache} from 'lru-cache';
import { transformContext } from './context-transformer';
import { ProxyNotReady } from './errors/proxyNotReady';
import { ProxyTimeout } from './errors/proxyTimeout';
import { UnknownError } from './errors/unknownError';
import { Unauthorized } from './errors/unauthorized';
import { LRUCache } from 'lru-cache';
import {
GoFeatureFlagProviderOptions,
GoFeatureFlagProxyRequest,
GoFeatureFlagProxyResponse,
GoFeatureFlagUser,
} from './model';
import {GoFeatureFlagDataCollectorHook} from './data-collector-hook';
import { GoFeatureFlagDataCollectorHook } from './data-collector-hook';
import hash from 'object-hash';
// 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.endpoint = options.endpoint;
if (!options.disableDataCollection){
this.dataCollectorHook = new GoFeatureFlagDataCollectorHook({
endpoint: this.endpoint,
timeout: this.timeout,
dataFlushInterval: options.dataFlushInterval
}, logger);
if (!options.disableDataCollection) {
this.dataCollectorHook = new GoFeatureFlagDataCollectorHook(
{
endpoint: this.endpoint,
timeout: this.timeout,
dataFlushInterval: options.dataFlushInterval,
},
logger,
);
this.hooks = [this.dataCollectorHook];
}
@ -83,8 +86,9 @@ export class GoFeatureFlagProvider implements Provider {
}
if (!options.disableCache) {
const cacheSize = options.flagCacheSize !== undefined && options.flagCacheSize !== 0 ? options.flagCacheSize : 10000;
this.cache = new LRUCache({maxSize: cacheSize, sizeCalculation: () => 1});
const cacheSize =
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() {
if (!this.disableDataCollection) {
this.dataCollectorHook?.init()
this.dataCollectorHook?.init();
}
this._status = ProviderStatus.READY;
}
get status(){
get status() {
return this._status;
}
@ -126,13 +130,13 @@ export class GoFeatureFlagProvider implements Provider {
async resolveBooleanEvaluation(
flagKey: string,
defaultValue: boolean,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<boolean>> {
return this.resolveEvaluationGoFeatureFlagProxy<boolean>(
flagKey,
defaultValue,
transformContext(context),
'boolean'
'boolean',
);
}
@ -151,14 +155,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<string>> {
return this.resolveEvaluationGoFeatureFlagProxy<string>(
flagKey,
defaultValue,
transformContext(context),
'string'
);
return this.resolveEvaluationGoFeatureFlagProxy<string>(flagKey, defaultValue, transformContext(context), 'string');
}
/**
@ -176,14 +175,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<number>> {
return this.resolveEvaluationGoFeatureFlagProxy<number>(
flagKey,
defaultValue,
transformContext(context),
'number'
);
return this.resolveEvaluationGoFeatureFlagProxy<number>(flagKey, defaultValue, transformContext(context), 'number');
}
/**
@ -201,14 +195,9 @@ export class GoFeatureFlagProvider implements Provider {
async resolveObjectEvaluation<U extends JsonValue>(
flagKey: string,
defaultValue: U,
context: EvaluationContext
context: EvaluationContext,
): Promise<ResolutionDetails<U>> {
return this.resolveEvaluationGoFeatureFlagProxy<U>(
flagKey,
defaultValue,
transformContext(context),
'object'
);
return this.resolveEvaluationGoFeatureFlagProxy<U>(flagKey, defaultValue, transformContext(context), 'object');
}
/**
@ -230,10 +219,10 @@ export class GoFeatureFlagProvider implements Provider {
flagKey: string,
defaultValue: T,
user: GoFeatureFlagUser,
expectedType: string
expectedType: string,
): Promise<ResolutionDetails<T>> {
// Check if the provider is ready to serve
if(this._status === ProviderStatus.NOT_READY){
if (this._status === ProviderStatus.NOT_READY) {
return {
value: defaultValue,
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
const endpointURL = new URL(this.endpoint);
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');
}
// Impossible to contact the relay-proxy
if (
axios.isAxiosError(error) &&
(error.code === 'ECONNREFUSED' || error.response?.status === 404)
) {
throw new ProxyNotReady(
`impossible to call go-feature-flag relay proxy on ${endpointURL}`,
error
);
if (axios.isAxiosError(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
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
throw new ProxyTimeout(
`impossible to retrieve the ${flagKey} on time`,
error
);
throw new ProxyTimeout(`impossible to retrieve the ${flagKey} on time`, error);
}
throw new UnknownError(
`unknown error while retrieving flag ${flagKey} for user ${user.key}`,
error
);
throw new UnknownError(`unknown error while retrieving flag ${flagKey} for user ${user.key}`, error);
}
// Check that we received the expectedType
if (typeof apiResponseData.value !== expectedType) {
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
if (apiResponseData.errorCode === ErrorCode.FLAG_NOT_FOUND) {
throw new FlagNotFoundError(
`Flag ${flagKey} was not found in your configuration`
);
throw new FlagNotFoundError(`Flag ${flagKey} was not found in your configuration`);
}
// Case of the flag is 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
// which variant it is.
return {value: defaultValue, reason: apiResponseData.reason};
return { value: defaultValue, reason: apiResponseData.reason };
}
const sdkResponse: ResolutionDetails<T> = {
@ -331,9 +306,9 @@ export class GoFeatureFlagProvider implements Provider {
if (this.cache !== undefined && apiResponseData.cacheable) {
if (this.cacheTTL === -1) {
this.cache.set(cacheKey, sdkResponse)
this.cache.set(cacheKey, sdkResponse);
} else {
this.cache.set(cacheKey, sdkResponse, {ttl: this.cacheTTL})
this.cache.set(cacheKey, sdkResponse, { ttl: this.cacheTTL });
}
}
return sdkResponse;

View File

@ -1,7 +1,4 @@
import {
ErrorCode,
EvaluationContextValue,
} from '@openfeature/server-sdk';
import { ErrorCode, EvaluationContextValue } from '@openfeature/server-sdk';
/**
* GoFeatureFlagUser is the representation of a user for GO Feature Flag
@ -57,25 +54,25 @@ export interface GoFeatureFlagProviderOptions {
apiKey?: string;
// 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.
// default: 10000
flagCacheSize?: number
flagCacheSize?: number;
// 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
// default: 1 minute
flagCacheTTL?: number
flagCacheTTL?: number;
// 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
// when calling the evaluation API.
// 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?: boolean
disableDataCollection?: boolean;
}
// GOFeatureFlagResolutionReasons allows to extends resolution reasons
@ -84,7 +81,6 @@ export declare enum GOFeatureFlagResolutionReasons {}
// GOFeatureFlagErrorCode allows to extends error codes
export declare enum GOFeatureFlagErrorCode {}
export interface DataCollectorRequest<T> {
events: FeatureEvent<T>[];
meta: Record<string, string>;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,45 +1,43 @@
import translateResult from './translate-result';
describe('translateResult', () => {
it.each([
true,
'potato',
42,
{ yes: 'no' },
])('puts the value into the result.', (value) => {
expect(translateResult<typeof value>({
value,
reason: {
kind: 'OFF',
},
}).value).toEqual(value);
it.each([true, 'potato', 42, { yes: 'no' }])('puts the value into the result.', (value) => {
expect(
translateResult<typeof value>({
value,
reason: {
kind: 'OFF',
},
}).value,
).toEqual(value);
});
it('converts the variationIndex into a string variant', () => {
expect(translateResult<boolean>({
value: true,
variationIndex: 9,
reason: {
kind: 'OFF',
},
}).variant).toEqual('9');
expect(
translateResult<boolean>({
value: true,
variationIndex: 9,
reason: {
kind: 'OFF',
},
}).variant,
).toEqual('9');
});
it.each([
'OFF',
'FALLTHROUGH',
'TARGET_MATCH',
'PREREQUISITE_FAILED',
'ERROR',
])('populates the resolution reason', (reason) => {
expect(translateResult<boolean>({
value: true,
variationIndex: 9,
reason: {
kind: reason,
},
}).reason).toEqual(reason);
});
it.each(['OFF', 'FALLTHROUGH', 'TARGET_MATCH', 'PREREQUISITE_FAILED', 'ERROR'])(
'populates the resolution reason',
(reason) => {
expect(
translateResult<boolean>({
value: true,
variationIndex: 9,
reason: {
kind: reason,
},
}).reason,
).toEqual(reason);
},
);
it('does not populate the errorCode when there is not an error', () => {
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', () => {
it('should be constructed with valid input - boolean', () => {
@ -66,7 +66,7 @@ describe('Flagd flag structure', () => {
expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('pi2');
expect(ff.targeting).toBe('');
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('pi2')).toStrictEqual({ value: 3.14, accuracy: 2 });
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.
*/
export interface Flag {
state: "ENABLED" | "DISABLED";
state: 'ENABLED' | 'DISABLED';
defaultVariant: string;
variants: { [key: string]: FlagValue };
targeting?: string;

View File

@ -1,5 +1,5 @@
import {FlagdCore} from './flagd-core';
import {GeneralError, StandardResolutionReasons, TypeMismatchError} from '@openfeature/core';
import { FlagdCore } from './flagd-core';
import { GeneralError, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core';
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"]}]}}}`;
@ -14,63 +14,61 @@ describe('flagd-core resolving', () => {
const resolved = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console);
expect(resolved.value).toBeTruthy();
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("on")
expect(resolved.variant).toBe('on');
});
it('should resolve string flag', () => {
const resolved = core.resolveStringEvaluation('myStringFlag', 'key2', {}, console);
expect(resolved.value).toBe('val1');
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("key1")
expect(resolved.variant).toBe('key1');
});
it('should resolve number flag', () => {
const resolved = core.resolveNumberEvaluation('myFloatFlag', 2.34, {}, console);
expect(resolved.value).toBe(1.23);
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("one")
expect(resolved.variant).toBe('one');
});
it('should resolve object flag', () => {
const resolved = core.resolveObjectEvaluation('myObjectFlag', {key: true}, {}, console);
expect(resolved.value).toStrictEqual({key: 'val'});
const resolved = core.resolveObjectEvaluation('myObjectFlag', { key: true }, {}, console);
expect(resolved.value).toStrictEqual({ key: 'val' });
expect(resolved.reason).toBe(StandardResolutionReasons.STATIC);
expect(resolved.variant).toBe("object1")
expect(resolved.variant).toBe('object1');
});
});
describe('flagd-core targeting evaluations', () => {
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]}}}}';
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]}}}}';
let core: FlagdCore;
beforeAll(() => {
core = new FlagdCore();
core.setConfigurations(targetingFlag)
core.setConfigurations(targetingFlag);
});
it('should resolve for correct inputs', () => {
const resolved = core.resolveStringEvaluation("targetedFlag", "none", {email: "admin@openfeature.dev"}, console);
expect(resolved.value).toBe("BBB")
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH)
expect(resolved.variant).toBe("second")
const resolved = core.resolveStringEvaluation('targetedFlag', 'none', { email: 'admin@openfeature.dev' }, console);
expect(resolved.value).toBe('BBB');
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe('second');
});
it('should fallback to default - missing targeting context data', () => {
const resolved = core.resolveStringEvaluation("targetedFlag", "none", {}, console);
expect(resolved.value).toBe("AAA")
expect(resolved.reason).toBe(StandardResolutionReasons.DEFAULT)
expect(resolved.variant).toBe("first")
const resolved = core.resolveStringEvaluation('targetedFlag', 'none', {}, console);
expect(resolved.value).toBe('AAA');
expect(resolved.reason).toBe(StandardResolutionReasons.DEFAULT);
expect(resolved.variant).toBe('first');
});
it('should handle short circuit fallbacks', () => {
const resolved = core.resolveBooleanEvaluation("shortCircuit", false, {favoriteNumber: 1}, console);
expect(resolved.value).toBe(true)
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH)
expect(resolved.variant).toBe("true")
const resolved = core.resolveBooleanEvaluation('shortCircuit', false, { favoriteNumber: 1 }, console);
expect(resolved.value).toBe(true);
expect(resolved.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
expect(resolved.variant).toBe('true');
});
});
describe('flagd-core validations', () => {
@ -85,25 +83,22 @@ describe('flagd-core validations', () => {
});
it('should validate flag type - eval int as boolean', () => {
expect(() => core.resolveBooleanEvaluation('myIntFlag', true, {}, console))
.toThrow(GeneralError)
expect(() => core.resolveBooleanEvaluation('myIntFlag', true, {}, console)).toThrow(GeneralError);
});
it('should validate flag status', () => {
const evaluation = core.resolveBooleanEvaluation('myBoolFlag', false, {}, console);
expect(evaluation).toBeTruthy()
expect(evaluation.value).toBe(false)
expect(evaluation.reason).toBe(StandardResolutionReasons.DISABLED)
expect(evaluation).toBeTruthy();
expect(evaluation.value).toBe(false);
expect(evaluation.reason).toBe(StandardResolutionReasons.DISABLED);
});
it('should validate variant', () => {
expect(() => core.resolveStringEvaluation('myStringFlag', 'hello', {}, console))
.toThrow(TypeMismatchError)
expect(() => core.resolveStringEvaluation('myStringFlag', 'hello', {}, console)).toThrow(TypeMismatchError);
});
it('should validate variant existence', () => {
expect(() => core.resolveNumberEvaluation('myIntFlag', 100, {}, console))
.toThrow(GeneralError)
expect(() => core.resolveNumberEvaluation('myIntFlag', 100, {}, console)).toThrow(GeneralError);
});
});

View File

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

View File

@ -57,6 +57,6 @@ describe('Flag configurations', () => {
const fibAlgo = flags.get('fibAlgo');
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 {FeatureFlag, Flag} from './feature-flag';
import { FeatureFlag, Flag } from './feature-flag';
import mydata from '../../flagd-schemas/json/flagd-definitions.json';
const ajv = new Ajv();

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
export const startsWithRule = 'starts_with'
export const endsWithRule = 'ends_with'
export const startsWithRule = 'starts_with';
export const endsWithRule = 'ends_with';
export function startsWithHandler(data: unknown) {
return compare(startsWithRule, data)
return compare(startsWithRule, data);
}
export function endsWithHandler(data: unknown) {
return compare(endsWithRule, data)
return compare(endsWithRule, data);
}
function compare(method: string, data: unknown): boolean {
@ -17,19 +17,19 @@ function compare(method: string, data: unknown): boolean {
const params = Array.from(data);
if (params.length != 2) {
return false
return false;
}
if (typeof params[0] !== 'string' || typeof params[1] !== 'string') {
return false
return false;
}
switch (method) {
case startsWithRule:
return params[0].startsWith(params[1])
return params[0].startsWith(params[1]);
case endsWithRule:
return params[0].endsWith(params[1])
return params[0].endsWith(params[1]);
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;
beforeAll(() => {
targeting = new Targeting();
})
});
it('should inject flag key as a property', () => {
const flagKey = "flagA"
const input = {'===': [{var: "$flagd.flagKey"}, flagKey]}
const flagKey = 'flagA';
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', () => {
const ts = Math.floor(Date.now() / 1000)
const input = {'>=': [{var: "$flagd.timestamp"}, ts]}
const ts = Math.floor(Date.now() / 1000);
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', () => {
const flagKey = "flagA"
const input = {'===': [{var: "$flagd.flagKey"}, flagKey]}
const flagKey = 'flagA';
const input = { '===': [{ var: '$flagd.flagKey' }, flagKey] };
const ctx = {
$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;
beforeAll(() => {
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", () => {
const input = {"ends_with": [{"var": "email"}, "abc.com"]}
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeTruthy()
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', () => {
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;
beforeAll(() => {
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", () => {
const input = {"starts_with": [{"var": "someNumber"}, "abc.com"]}
expect(targeting.applyTargeting("flag", input, {someNumber: 123456})).toBeFalsy()
it('missing input', () => {
const input = { starts_with: [{ var: 'email' }] };
expect(targeting.applyTargeting('flag', input, { email: 'admin@abc.com' })).toBeFalsy();
});
it("non string comparator", () => {
const input = {"starts_with": [{"var": "email"}, 123456]}
expect(targeting.applyTargeting("flag", input, {email: "admin@abc.com"})).toBeFalsy()
it('non string variable', () => {
const input = { starts_with: [{ var: 'someNumber' }, 'abc.com'] };
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;
beforeAll(() => {
targeting = new Targeting();
})
});
it('should support equal operator', () => {
const input = {"sem_ver": ['v1.2.3', "=", "1.2.3"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '=', '1.2.3'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support neq operator', () => {
const input = {"sem_ver": ['v1.2.3', "!=", "1.2.4"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '!=', '1.2.4'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support lt operator', () => {
const input = {"sem_ver": ['v1.2.3', "<", "1.2.4"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '<', '1.2.4'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support lte operator', () => {
const input = {"sem_ver": ['v1.2.3', "<=", "1.2.3"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '<=', '1.2.3'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support gte operator', () => {
const input = {"sem_ver": ['v1.2.3', ">=", "1.2.3"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '>=', '1.2.3'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support gt operator', () => {
const input = {"sem_ver": ['v1.2.4', ">", "1.2.3"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.4', '>', '1.2.3'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support major comparison operator', () => {
const input = {"sem_ver": ["v1.2.3", "^", "v1.0.0"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v1.2.3', '^', 'v1.0.0'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should support minor comparison operator', () => {
const input = {"sem_ver": ["v5.0.3", "~", "v5.0.8"]}
expect(targeting.applyTargeting("flag", input, {})).toBeTruthy()
const input = { sem_ver: ['v5.0.3', '~', 'v5.0.8'] };
expect(targeting.applyTargeting('flag', input, {})).toBeTruthy();
});
it('should handle unknown operator', () => {
const input = {"sem_ver": ["v1.0.0", "-", "v1.0.0"]}
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy()
const input = { sem_ver: ['v1.0.0', '-', 'v1.0.0'] };
expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
});
it('should handle invalid inputs', () => {
const input = {"sem_ver": ["myVersion_1", "=", "myVersion_1"]}
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy()
const input = { sem_ver: ['myVersion_1', '=', 'myVersion_1'] };
expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
});
it('should validate inputs', () => {
const input = {"sem_ver": ["myVersion_2", "+", "myVersion_1", "myVersion_1"]}
expect(targeting.applyTargeting("flag", input, {})).toBeFalsy()
const input = { sem_ver: ['myVersion_2', '+', 'myVersion_1', 'myVersion_1'] };
expect(targeting.applyTargeting('flag', input, {})).toBeFalsy();
});
})
});
describe("fractional operator", () => {
describe('fractional operator', () => {
let targeting: Targeting;
beforeAll(() => {
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 = {
fractional: [
{"var": "key"},
["red", 50],
["blue", 50]
]
}
['red', 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", () => {
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", () => {
describe('fractional operator should validate', () => {
let targeting: Targeting;
beforeAll(() => {
targeting = new Targeting();
})
});
it("bucket sum to be 100", () => {
it('bucket sum to be 100', () => {
const input = {
fractional: [
["red", 55],
["blue", 55]
]
}
['red', 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 = {
fractional: [
["red", 50],
[100, 50]
]
}
['red', 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 {endsWithHandler, endsWithRule, startsWithHandler, startsWithRule} from "./string-comp";
import {semVer, semVerRule} from "./sem-ver";
import {fractional, fractionalRule} from "./fractional";
import {flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey} from "./common";
import { LogicEngine } from 'json-logic-engine';
import { endsWithHandler, endsWithRule, startsWithHandler, startsWithRule } from './string-comp';
import { semVer, semVerRule } from './sem-ver';
import { fractional, fractionalRule } from './fractional';
import { flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey } from './common';
export class Targeting {
private readonly _logicEngine: LogicEngine;
@ -19,7 +19,7 @@ export class Targeting {
applyTargeting(flagKey: string, logic: unknown, data: object): unknown {
if (Object.hasOwn(data, flagdPropertyKey)) {
console.warn(`overwriting ${flagdPropertyKey} property in the context`)
console.warn(`overwriting ${flagdPropertyKey} property in the context`);
}
const ctxData = {
@ -28,7 +28,7 @@ export class Targeting {
[flagKeyPropertyKey]: flagKey,
[timestampPropertyKey]: Math.floor(Date.now() / 1000),
},
}
};
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) => {
tree.rename(
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
json.type = undefined
json.type = undefined;
// client packages have a web-sdk dep, server js-sdk
json.peerDependencies = schema.category === 'client' ? {
'@openfeature/web-sdk': '>=0.4.0',
} : {
'@openfeature/server-sdk': '^1.6.0',
}
json.peerDependencies =
schema.category === 'client'
? {
'@openfeature/web-sdk': '>=0.4.0',
}
: {
'@openfeature/server-sdk': '^1.6.0',
};
return json;
});