feat: add evaluation-scoped hook data (#1216)
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
This commit is contained in:
parent
d8bd93b6d5
commit
07af3a9eda
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
|
@ -276,22 +277,26 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
const mergedContext = this.mergeContexts(invocationContext);
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context: mergedContext,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
|
||||
const frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
|
@ -306,53 +311,71 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
await this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
|
||||
await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
for (const hook of hooks) {
|
||||
// freeze the hookContext
|
||||
Object.freeze(hookContext);
|
||||
private async beforeHooks(
|
||||
hooks: Hook[],
|
||||
hookContexts: HookContext[],
|
||||
mergedContext: EvaluationContext,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
let accumulatedContext = mergedContext;
|
||||
|
||||
// use Object.assign to avoid modification of frozen hookContext
|
||||
Object.assign(hookContext.context, {
|
||||
...hookContext.context,
|
||||
...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))),
|
||||
});
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
|
||||
// Update the context on the stable hook context object
|
||||
Object.assign(hookContext.context, accumulatedContext);
|
||||
|
||||
const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
if (hookResult) {
|
||||
accumulatedContext = {
|
||||
...accumulatedContext,
|
||||
...hookResult,
|
||||
};
|
||||
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
Object.assign(hookContexts[hookContextIndex].context, accumulatedContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after before hooks, freeze the EvaluationContext.
|
||||
return Object.freeze(hookContext.context);
|
||||
return Object.freeze(accumulatedContext);
|
||||
}
|
||||
|
||||
private async afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
|
@ -366,13 +389,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private async finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
await hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<
|
||||
FlagValue,
|
||||
TData,
|
||||
Promise<EvaluationContext | void> | EvaluationContext | void,
|
||||
Promise<void> | void
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,508 @@
|
|||
import { OpenFeature } from '../src';
|
||||
import type { Client } from '../src/client';
|
||||
import type {
|
||||
JsonValue,
|
||||
ResolutionDetails,
|
||||
HookContext,
|
||||
BeforeHookContext,
|
||||
HookData} from '@openfeature/core';
|
||||
import {
|
||||
StandardResolutionReasons
|
||||
} from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// Typed hook example demonstrating improved type safety
|
||||
interface OpenTelemetryData {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
class TypedOpenTelemetryHook implements Hook {
|
||||
spanId?: string;
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const spanId = `span-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// Demonstrate that we can cast for type safety while maintaining compatibility
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
|
||||
// Type-safe setting with proper intellisense
|
||||
typedHookData.set('spanId', spanId);
|
||||
typedHookData.set('traceId', traceId);
|
||||
typedHookData.set('startTime', Date.now());
|
||||
typedHookData.set('attributes', {
|
||||
flagKey: hookContext.flagKey,
|
||||
clientName: hookContext.clientMetadata.name || 'unknown',
|
||||
providerName: hookContext.providerMetadata.name,
|
||||
});
|
||||
|
||||
this.spanId = spanId;
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Type-safe getting with proper return types
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const startTime: number | undefined = typedHookData.get('startTime');
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
|
||||
if (startTime && spanId) {
|
||||
this.duration = Date.now() - startTime;
|
||||
// Simulate span completion
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const typedHookData = hookContext.hookData as unknown as HookData<OpenTelemetryData>;
|
||||
const spanId: string | undefined = typedHookData.get('spanId');
|
||||
if (spanId) {
|
||||
// Mark span as error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
}
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
async error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = Date.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
async resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
async resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data', () => {
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
OpenFeature.clearHooks();
|
||||
await OpenFeature.setProviderAndWait(MOCK_PROVIDER);
|
||||
client = OpenFeature.getClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OpenFeature.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', async () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', async () => {
|
||||
await OpenFeature.setProviderAndWait(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', async () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', async () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', async () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', async () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
OpenFeature.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', async () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', async () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
async finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
await client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', async () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
async before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
async after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
|
||||
it('should support typed hook data for better type safety', async () => {
|
||||
const typedHook = new TypedOpenTelemetryHook();
|
||||
client.addHooks(typedHook);
|
||||
|
||||
await client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(typedHook.spanId).toBeDefined();
|
||||
expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/);
|
||||
expect(typedHook.duration).toBeDefined();
|
||||
expect(typeof typedHook.duration).toBe('number');
|
||||
expect(typedHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* A mutable data structure for hooks to maintain state across their lifecycle.
|
||||
* Each hook instance gets its own isolated data store that persists for the
|
||||
* duration of a single flag evaluation.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export interface HookData<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* Sets a value in the hook data store.
|
||||
* @param key The key to store the value under
|
||||
* @param value The value to store
|
||||
*/
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void;
|
||||
set(key: string, value: unknown): void;
|
||||
|
||||
/**
|
||||
* Gets a value from the hook data store.
|
||||
* @param key The key to retrieve the value for
|
||||
* @returns The stored value, or undefined if not found
|
||||
*/
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined;
|
||||
get(key: string): unknown;
|
||||
|
||||
/**
|
||||
* Checks if a key exists in the hook data store.
|
||||
* @param key The key to check
|
||||
* @returns True if the key exists, false otherwise
|
||||
*/
|
||||
has<K extends keyof TData>(key: K): boolean;
|
||||
has(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Deletes a value from the hook data store.
|
||||
* @param key The key to delete
|
||||
* @returns True if the key was deleted, false if it didn't exist
|
||||
*/
|
||||
delete<K extends keyof TData>(key: K): boolean;
|
||||
delete(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Clears all values from the hook data store.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of HookData using a Map.
|
||||
* @template TData - A record type that defines the shape of the stored data
|
||||
*/
|
||||
export class MapHookData<TData = Record<string, unknown>> implements HookData<TData> {
|
||||
private readonly data = new Map<keyof TData, TData[keyof TData]>();
|
||||
|
||||
set<K extends keyof TData>(key: K, value: TData[K]): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
get<K extends keyof TData>(key: K): TData[K] | undefined {
|
||||
return this.data.get(key) as TData[K] | undefined;
|
||||
}
|
||||
|
||||
has<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
delete<K extends keyof TData>(key: K): boolean {
|
||||
return this.data.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
import type { BeforeHookContext, HookContext, HookHints } from './hooks';
|
||||
import type { EvaluationDetails, FlagValue } from '../evaluation';
|
||||
|
||||
export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = unknown, HooksReturn = unknown> {
|
||||
export interface BaseHook<
|
||||
T extends FlagValue = FlagValue,
|
||||
TData = Record<string, unknown>,
|
||||
BeforeHookReturn = unknown,
|
||||
HooksReturn = unknown
|
||||
> {
|
||||
/**
|
||||
* Runs before flag values are resolved from the provider.
|
||||
* If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext.
|
||||
* @param hookContext
|
||||
* @param hookHints
|
||||
*/
|
||||
before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn;
|
||||
before?(hookContext: BeforeHookContext<T, TData>, hookHints?: HookHints): BeforeHookReturn;
|
||||
|
||||
/**
|
||||
* Runs after flag values are successfully resolved from the provider.
|
||||
|
|
@ -17,7 +22,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
after?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
|
|
@ -28,7 +33,7 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param error
|
||||
* @param hookHints
|
||||
*/
|
||||
error?(hookContext: Readonly<HookContext<T>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
error?(hookContext: Readonly<HookContext<T, TData>>, error: unknown, hookHints?: HookHints): HooksReturn;
|
||||
|
||||
/**
|
||||
* Runs after all other hook stages, regardless of success or error.
|
||||
|
|
@ -37,8 +42,9 @@ export interface BaseHook<T extends FlagValue = FlagValue, BeforeHookReturn = un
|
|||
* @param hookHints
|
||||
*/
|
||||
finally?(
|
||||
hookContext: Readonly<HookContext<T>>,
|
||||
hookContext: Readonly<HookContext<T, TData>>,
|
||||
evaluationDetails: EvaluationDetails<T>,
|
||||
hookHints?: HookHints,
|
||||
): HooksReturn;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider';
|
|||
import type { ClientMetadata } from '../client';
|
||||
import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation';
|
||||
import type { Logger } from '../logger';
|
||||
import type { HookData } from './hook-data';
|
||||
|
||||
export type HookHints = Readonly<Record<string, unknown>>;
|
||||
|
||||
export interface HookContext<T extends FlagValue = FlagValue> {
|
||||
export interface HookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> {
|
||||
readonly flagKey: string;
|
||||
readonly defaultValue: T;
|
||||
readonly flagValueType: FlagValueType;
|
||||
|
|
@ -13,8 +14,9 @@ export interface HookContext<T extends FlagValue = FlagValue> {
|
|||
readonly clientMetadata: ClientMetadata;
|
||||
readonly providerMetadata: ProviderMetadata;
|
||||
readonly logger: Logger;
|
||||
readonly hookData: HookData<TData>;
|
||||
}
|
||||
|
||||
export interface BeforeHookContext extends HookContext {
|
||||
export interface BeforeHookContext<T extends FlagValue = FlagValue, TData = Record<string, unknown>> extends HookContext<T, TData> {
|
||||
context: EvaluationContext;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './hook';
|
||||
export * from './hooks';
|
||||
export * from './evaluation-lifecycle';
|
||||
export * from './hook-data';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks';
|
||||
import { MapHookData } from '../src/hooks';
|
||||
import type { FlagValue } from '../src/evaluation';
|
||||
|
||||
describe('Hook Data Type Safety', () => {
|
||||
it('should provide type safety with typed hook data', () => {
|
||||
// Define a strict type for hook data
|
||||
interface MyHookData {
|
||||
startTime: number;
|
||||
userId: string;
|
||||
metadata: { version: string; feature: boolean };
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const hookData = new MapHookData<MyHookData>();
|
||||
|
||||
// Type-safe setting and getting
|
||||
hookData.set('startTime', 123456);
|
||||
hookData.set('userId', 'user-123');
|
||||
hookData.set('metadata', { version: '1.0.0', feature: true });
|
||||
hookData.set('tags', ['tag1', 'tag2']);
|
||||
|
||||
// TypeScript should infer the correct return types
|
||||
const startTime: number | undefined = hookData.get('startTime');
|
||||
const userId: string | undefined = hookData.get('userId');
|
||||
const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata');
|
||||
const tags: string[] | undefined = hookData.get('tags');
|
||||
|
||||
// Verify the values
|
||||
expect(startTime).toBe(123456);
|
||||
expect(userId).toBe('user-123');
|
||||
expect(metadata).toEqual({ version: '1.0.0', feature: true });
|
||||
expect(tags).toEqual(['tag1', 'tag2']);
|
||||
|
||||
// Type-safe existence checks
|
||||
expect(hookData.has('startTime')).toBe(true);
|
||||
expect(hookData.has('userId')).toBe(true);
|
||||
expect(hookData.has('metadata')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(true);
|
||||
|
||||
// Type-safe deletion
|
||||
expect(hookData.delete('tags')).toBe(true);
|
||||
expect(hookData.has('tags')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support untyped usage for backward compatibility', () => {
|
||||
const hookData: HookData = new MapHookData();
|
||||
|
||||
// Untyped usage still works
|
||||
hookData.set('anyKey', 'anyValue');
|
||||
hookData.set('numberKey', 42);
|
||||
hookData.set('objectKey', { nested: true });
|
||||
|
||||
const value: unknown = hookData.get('anyKey');
|
||||
const numberValue: unknown = hookData.get('numberKey');
|
||||
const objectValue: unknown = hookData.get('objectKey');
|
||||
|
||||
expect(value).toBe('anyValue');
|
||||
expect(numberValue).toBe(42);
|
||||
expect(objectValue).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should support mixed usage with typed and untyped keys', () => {
|
||||
interface PartiallyTypedData {
|
||||
correlationId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const hookData: HookData<PartiallyTypedData> = new MapHookData<PartiallyTypedData>();
|
||||
|
||||
// Typed usage
|
||||
hookData.set('correlationId', 'abc-123');
|
||||
hookData.set('timestamp', Date.now());
|
||||
|
||||
// Untyped usage for additional keys
|
||||
hookData.set('dynamicKey', 'dynamicValue');
|
||||
|
||||
// Type-safe retrieval for typed keys
|
||||
const correlationId: string | undefined = hookData.get('correlationId');
|
||||
const timestamp: number | undefined = hookData.get('timestamp');
|
||||
|
||||
// Untyped retrieval for dynamic keys
|
||||
const dynamicValue: unknown = hookData.get('dynamicKey');
|
||||
|
||||
expect(correlationId).toBe('abc-123');
|
||||
expect(typeof timestamp).toBe('number');
|
||||
expect(dynamicValue).toBe('dynamicValue');
|
||||
});
|
||||
|
||||
it('should work with complex nested types', () => {
|
||||
interface ComplexHookData {
|
||||
request: {
|
||||
id: string;
|
||||
headers: Record<string, string>;
|
||||
body?: { [key: string]: unknown };
|
||||
};
|
||||
response: {
|
||||
status: number;
|
||||
data: unknown;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
metrics: {
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const hookData: HookData<ComplexHookData> = new MapHookData<ComplexHookData>();
|
||||
|
||||
const requestData = {
|
||||
id: 'req-123',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { flag: 'test-flag' },
|
||||
};
|
||||
|
||||
hookData.set('request', requestData);
|
||||
hookData.set('metrics', { startTime: Date.now() });
|
||||
|
||||
const retrievedRequest = hookData.get('request');
|
||||
const retrievedMetrics = hookData.get('metrics');
|
||||
|
||||
expect(retrievedRequest).toEqual(requestData);
|
||||
expect(retrievedMetrics?.startTime).toBeDefined();
|
||||
expect(typeof retrievedMetrics?.startTime).toBe('number');
|
||||
});
|
||||
|
||||
it('should support generic type inference', () => {
|
||||
// This function demonstrates how the generic types work in practice
|
||||
function createTypedHookData<T>(): HookData<T> {
|
||||
return new MapHookData<T>();
|
||||
}
|
||||
|
||||
interface TimingData {
|
||||
start: number;
|
||||
checkpoint: number;
|
||||
}
|
||||
|
||||
const timingHookData = createTypedHookData<TimingData>();
|
||||
|
||||
timingHookData.set('start', performance.now());
|
||||
timingHookData.set('checkpoint', performance.now());
|
||||
|
||||
const start: number | undefined = timingHookData.get('start');
|
||||
const checkpoint: number | undefined = timingHookData.get('checkpoint');
|
||||
|
||||
expect(typeof start).toBe('number');
|
||||
expect(typeof checkpoint).toBe('number');
|
||||
});
|
||||
|
||||
it('should work with BaseHook interface without casting', () => {
|
||||
interface TestHookData {
|
||||
testId: string;
|
||||
startTime: number;
|
||||
metadata: { version: string };
|
||||
}
|
||||
|
||||
class TestTypedHook implements BaseHook<FlagValue, TestHookData> {
|
||||
capturedData: { testId?: string; duration?: number } = {};
|
||||
|
||||
before(hookContext: BeforeHookContext<FlagValue, TestHookData>) {
|
||||
// No casting needed - TypeScript knows the types
|
||||
hookContext.hookData.set('testId', 'test-123');
|
||||
hookContext.hookData.set('startTime', Date.now());
|
||||
hookContext.hookData.set('metadata', { version: '1.0.0' });
|
||||
}
|
||||
|
||||
after(hookContext: HookContext<FlagValue, TestHookData>) {
|
||||
// Type-safe getting with proper return types
|
||||
const testId: string | undefined = hookContext.hookData.get('testId');
|
||||
const startTime: number | undefined = hookContext.hookData.get('startTime');
|
||||
|
||||
if (testId && startTime) {
|
||||
this.capturedData = {
|
||||
testId,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hook = new TestTypedHook();
|
||||
|
||||
// Create mock contexts that satisfy the BaseHook interface
|
||||
const mockBeforeContext: BeforeHookContext<FlagValue, TestHookData> = {
|
||||
flagKey: 'test-flag',
|
||||
defaultValue: true,
|
||||
flagValueType: 'boolean',
|
||||
context: {},
|
||||
clientMetadata: {
|
||||
name: 'test-client',
|
||||
domain: 'test-domain',
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
},
|
||||
providerMetadata: { name: 'test-provider' },
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
hookData: new MapHookData<TestHookData>(),
|
||||
};
|
||||
|
||||
const mockAfterContext: HookContext<FlagValue, TestHookData> = {
|
||||
...mockBeforeContext,
|
||||
context: Object.freeze({}),
|
||||
};
|
||||
|
||||
// Execute the hook methods
|
||||
hook.before!(mockBeforeContext);
|
||||
hook.after!(mockAfterContext);
|
||||
|
||||
// Verify the typed hook worked correctly
|
||||
expect(hook.capturedData.testId).toBe('test-123');
|
||||
expect(hook.capturedData.duration).toBeDefined();
|
||||
expect(typeof hook.capturedData.duration).toBe('number');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
|
|||
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
|
||||
import type { HookContext } from '../src/hooks/hooks';
|
||||
import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry';
|
||||
import { MapHookData } from '../src/hooks/hook-data';
|
||||
|
||||
describe('evaluationEvent', () => {
|
||||
const flagKey = 'test-flag';
|
||||
|
|
@ -25,6 +26,7 @@ describe('evaluationEvent', () => {
|
|||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
hookData: new MapHookData(),
|
||||
};
|
||||
|
||||
it('should return basic event body with mandatory fields', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
StandardResolutionReasons,
|
||||
instantiateErrorByErrorCode,
|
||||
statusMatchesEvent,
|
||||
MapHookData,
|
||||
} from '@openfeature/core';
|
||||
import type { FlagEvaluationOptions } from '../../evaluation';
|
||||
import type { ProviderEvents } from '../../events';
|
||||
|
|
@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client {
|
|||
...this.apiContextAccessor(this?.options?.domain),
|
||||
};
|
||||
|
||||
// this reference cannot change during the course of evaluation
|
||||
// it may be used as a key in WeakMaps
|
||||
const hookContext: Readonly<HookContext> = {
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
};
|
||||
// Create hook context instances for each hook (stable object references for the entire evaluation)
|
||||
// This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods
|
||||
// NOTE: Uses the reversed order to reduce the number of times we have to calculate the index.
|
||||
const hookContexts = allHooksReversed.map<HookContext>(() =>
|
||||
Object.freeze({
|
||||
flagKey,
|
||||
defaultValue,
|
||||
flagValueType: flagType,
|
||||
clientMetadata: this.metadata,
|
||||
providerMetadata: this._provider.metadata,
|
||||
context,
|
||||
logger: this._logger,
|
||||
hookData: new MapHookData(),
|
||||
}),
|
||||
);
|
||||
|
||||
let evaluationDetails: EvaluationDetails<T>;
|
||||
|
||||
try {
|
||||
this.beforeHooks(allHooks, hookContext, options);
|
||||
this.beforeHooks(allHooks, hookContexts, options);
|
||||
|
||||
this.shortCircuitIfNotReady();
|
||||
|
||||
|
|
@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
if (resolutionDetails.errorCode) {
|
||||
const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage);
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata);
|
||||
} else {
|
||||
this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options);
|
||||
this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options);
|
||||
evaluationDetails = resolutionDetails;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.errorHooks(allHooksReversed, hookContext, err, options);
|
||||
this.errorHooks(allHooksReversed, hookContexts, err, options);
|
||||
evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err);
|
||||
}
|
||||
this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options);
|
||||
this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options);
|
||||
return evaluationDetails;
|
||||
}
|
||||
|
||||
private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) {
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
|
||||
for (const hook of hooks) {
|
||||
private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks
|
||||
const hookContext = hookContexts[hookContextIndex];
|
||||
Object.freeze(hookContext);
|
||||
Object.freeze(hookContext.context);
|
||||
hook?.before?.(hookContext, Object.freeze(options.hookHints));
|
||||
}
|
||||
}
|
||||
|
||||
private afterHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "after" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.after?.(hookContext, evaluationDetails, options.hookHints);
|
||||
}
|
||||
}
|
||||
|
||||
private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) {
|
||||
private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) {
|
||||
// run "error" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.error?.(hookContext, err, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'error' hook: ${err}`);
|
||||
|
|
@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client {
|
|||
|
||||
private finallyHooks(
|
||||
hooks: Hook[],
|
||||
hookContext: HookContext,
|
||||
hookContexts: HookContext[],
|
||||
evaluationDetails: EvaluationDetails<FlagValue>,
|
||||
options: FlagEvaluationOptions,
|
||||
) {
|
||||
// run "finally" hooks sequentially
|
||||
for (const hook of hooks) {
|
||||
for (const [index, hook] of hooks.entries()) {
|
||||
try {
|
||||
const hookContext = hookContexts[index];
|
||||
hook?.finally?.(hookContext, evaluationDetails, options.hookHints);
|
||||
} catch (err) {
|
||||
this._logger.error(`Unhandled error during 'finally' hook: ${err}`);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
import type { BaseHook, FlagValue } from '@openfeature/core';
|
||||
|
||||
export type Hook = BaseHook<FlagValue, void, void>;
|
||||
export type Hook<TData = Record<string, unknown>> = BaseHook<FlagValue, TData, void, void>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,436 @@
|
|||
import { OpenFeatureAPI } from '../src/open-feature';
|
||||
import type { Client } from '../src/client';
|
||||
import type { JsonValue, ResolutionDetails, HookContext, BeforeHookContext } from '@openfeature/core';
|
||||
import { StandardResolutionReasons } from '@openfeature/core';
|
||||
import type { Provider } from '../src/provider';
|
||||
import type { Hook } from '../src/hooks';
|
||||
|
||||
const BOOLEAN_VALUE = true;
|
||||
const STRING_VALUE = 'val';
|
||||
const NUMBER_VALUE = 1;
|
||||
const OBJECT_VALUE = { key: 'value' };
|
||||
|
||||
// A test hook that stores data in the before stage and retrieves it in after/error/finally
|
||||
class TestHookWithData implements Hook {
|
||||
beforeData: unknown;
|
||||
afterData: unknown;
|
||||
errorData: unknown;
|
||||
finallyData: unknown;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store some data
|
||||
hookContext.hookData.set('testKey', 'testValue');
|
||||
hookContext.hookData.set('timestamp', Date.now());
|
||||
hookContext.hookData.set('object', { nested: 'value' });
|
||||
this.beforeData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.afterData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.errorData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
// Retrieve data stored in before
|
||||
this.finallyData = hookContext.hookData.get('testKey');
|
||||
}
|
||||
}
|
||||
|
||||
// A timing hook that measures evaluation duration
|
||||
class TimingHook implements Hook {
|
||||
duration?: number;
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('startTime', performance.now());
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
error(hookContext: HookContext) {
|
||||
const startTime = hookContext.hookData.get('startTime') as number;
|
||||
if (startTime) {
|
||||
this.duration = performance.now() - startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that tests hook data isolation
|
||||
class IsolationTestHook implements Hook {
|
||||
hookId: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.hookId = id;
|
||||
}
|
||||
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId) {
|
||||
throw new Error('Hook data isolation violated! Data is set in before hook.');
|
||||
}
|
||||
|
||||
// Each hook instance should have its own data
|
||||
hookContext.hookData.set('hookId', this.hookId);
|
||||
hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`);
|
||||
}
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify we can only see our own data
|
||||
const storedId = hookContext.hookData.get('hookId');
|
||||
if (storedId !== this.hookId) {
|
||||
throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock provider for testing
|
||||
const MOCK_PROVIDER: Provider = {
|
||||
metadata: { name: 'mock-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
return {
|
||||
value: BOOLEAN_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
return {
|
||||
value: STRING_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
return {
|
||||
value: NUMBER_VALUE,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
return {
|
||||
value: OBJECT_VALUE as unknown as T,
|
||||
variant: 'default',
|
||||
reason: StandardResolutionReasons.DEFAULT,
|
||||
};
|
||||
},
|
||||
} as Provider;
|
||||
|
||||
// Mock provider that throws an error
|
||||
const ERROR_PROVIDER: Provider = {
|
||||
metadata: { name: 'error-provider' },
|
||||
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveStringEvaluation(): ResolutionDetails<string> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveNumberEvaluation(): ResolutionDetails<number> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
|
||||
throw new Error('Provider error');
|
||||
},
|
||||
};
|
||||
|
||||
describe('Hook Data (Web SDK)', () => {
|
||||
let client: Client;
|
||||
let api: OpenFeatureAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
api = OpenFeatureAPI.getInstance();
|
||||
api.clearHooks();
|
||||
api.setProvider(MOCK_PROVIDER);
|
||||
client = api.getClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
api.clearProviders();
|
||||
});
|
||||
|
||||
describe('Basic Hook Data Functionality', () => {
|
||||
it('should allow hooks to store and retrieve data across stages', () => {
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was stored in before and retrieved in all other stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.afterData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
});
|
||||
|
||||
it('should support storing different data types', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storedValues: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Store various types
|
||||
hookContext.hookData.set('string', 'test');
|
||||
hookContext.hookData.set('number', 42);
|
||||
hookContext.hookData.set('boolean', true);
|
||||
hookContext.hookData.set('object', { key: 'value' });
|
||||
hookContext.hookData.set('array', [1, 2, 3]);
|
||||
hookContext.hookData.set('null', null);
|
||||
hookContext.hookData.set('undefined', undefined);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
storedValues.string = hookContext.hookData.get('string');
|
||||
storedValues.number = hookContext.hookData.get('number');
|
||||
storedValues.boolean = hookContext.hookData.get('boolean');
|
||||
storedValues.object = hookContext.hookData.get('object');
|
||||
storedValues.array = hookContext.hookData.get('array');
|
||||
storedValues.null = hookContext.hookData.get('null');
|
||||
storedValues.undefined = hookContext.hookData.get('undefined');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(storedValues.string).toBe('test');
|
||||
expect(storedValues.number).toBe(42);
|
||||
expect(storedValues.boolean).toBe(true);
|
||||
expect(storedValues.object).toEqual({ key: 'value' });
|
||||
expect(storedValues.array).toEqual([1, 2, 3]);
|
||||
expect(storedValues.null).toBeNull();
|
||||
expect(storedValues.undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle hook data in error scenarios', () => {
|
||||
api.setProvider(ERROR_PROVIDER);
|
||||
const hook = new TestHookWithData();
|
||||
client.addHooks(hook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
// Verify data was accessible in error and finally stages
|
||||
expect(hook.beforeData).toBe('testValue');
|
||||
expect(hook.errorData).toBe('testValue');
|
||||
expect(hook.finallyData).toBe('testValue');
|
||||
expect(hook.afterData).toBeUndefined(); // after should not run on error
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data API', () => {
|
||||
it('should support has() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('exists', 'value');
|
||||
hasResults.beforeExists = hookContext.hookData.has('exists');
|
||||
hasResults.beforeNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
hasResults.afterExists = hookContext.hookData.has('exists');
|
||||
hasResults.afterNotExists = hookContext.hookData.has('notExists');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(hasResults.beforeExists).toBe(true);
|
||||
expect(hasResults.beforeNotExists).toBe(false);
|
||||
expect(hasResults.afterExists).toBe(true);
|
||||
expect(hasResults.afterNotExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should support delete() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('toDelete', 'value');
|
||||
deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteResult = hookContext.hookData.delete('toDelete');
|
||||
deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete');
|
||||
deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(deleteResults.hasBeforeDelete).toBe(true);
|
||||
expect(deleteResults.deleteResult).toBe(true);
|
||||
expect(deleteResults.hasAfterDelete).toBe(false);
|
||||
expect(deleteResults.deleteAgainResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should support clear() method', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clearResults: any = {};
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('key1', 'value1');
|
||||
hookContext.hookData.set('key2', 'value2');
|
||||
hookContext.hookData.set('key3', 'value3');
|
||||
clearResults.hasBeforeClear = hookContext.hookData.has('key1');
|
||||
hookContext.hookData.clear();
|
||||
clearResults.hasAfterClear = hookContext.hookData.has('key1');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
// Verify all data was cleared
|
||||
clearResults.afterHasKey1 = hookContext.hookData.has('key1');
|
||||
clearResults.afterHasKey2 = hookContext.hookData.has('key2');
|
||||
clearResults.afterHasKey3 = hookContext.hookData.has('key3');
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(clearResults.hasBeforeClear).toBe(true);
|
||||
expect(clearResults.hasAfterClear).toBe(false);
|
||||
expect(clearResults.afterHasKey1).toBe(false);
|
||||
expect(clearResults.afterHasKey2).toBe(false);
|
||||
expect(clearResults.afterHasKey3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Data Isolation', () => {
|
||||
it('should isolate data between different hook instances', () => {
|
||||
const hook1 = new IsolationTestHook('hook1');
|
||||
const hook2 = new IsolationTestHook('hook2');
|
||||
const hook3 = new IsolationTestHook('hook3');
|
||||
|
||||
client.addHooks(hook1, hook2, hook3);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should isolate data between the same hook instance', () => {
|
||||
const hook = new IsolationTestHook('hook');
|
||||
|
||||
client.addHooks(hook, hook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not share data between different evaluations', () => {
|
||||
let firstEvalData: unknown;
|
||||
let secondEvalData: unknown;
|
||||
|
||||
const hook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
// Check if data exists from previous evaluation
|
||||
const existingData = hookContext.hookData.get('evalData');
|
||||
if (existingData) {
|
||||
throw new Error('Hook data leaked between evaluations!');
|
||||
}
|
||||
hookContext.hookData.set('evalData', 'evaluation-specific');
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
if (!firstEvalData) {
|
||||
firstEvalData = hookContext.hookData.get('evalData');
|
||||
} else {
|
||||
secondEvalData = hookContext.hookData.get('evalData');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(hook);
|
||||
|
||||
// First evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
// Second evaluation
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(firstEvalData).toBe('evaluation-specific');
|
||||
expect(secondEvalData).toBe('evaluation-specific');
|
||||
});
|
||||
|
||||
it('should isolate data between global, client, and invocation hooks', () => {
|
||||
const globalHook = new IsolationTestHook('global');
|
||||
const clientHook = new IsolationTestHook('client');
|
||||
const invocationHook = new IsolationTestHook('invocation');
|
||||
|
||||
api.addHooks(globalHook);
|
||||
client.addHooks(clientHook);
|
||||
|
||||
expect(client.getBooleanValue('test-flag', false, { hooks: [invocationHook] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use Cases', () => {
|
||||
it('should support timing measurements', () => {
|
||||
const timingHook = new TimingHook();
|
||||
client.addHooks(timingHook);
|
||||
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(timingHook.duration).toBeDefined();
|
||||
expect(timingHook.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should support multi-stage validation accumulation', () => {
|
||||
let finalErrors: string[] = [];
|
||||
|
||||
const validationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
hookContext.hookData.set('errors', []);
|
||||
|
||||
// Simulate validation
|
||||
const errors = hookContext.hookData.get('errors') as string[];
|
||||
if (!hookContext.context.userId) {
|
||||
errors.push('Missing userId');
|
||||
}
|
||||
if (!hookContext.context.region) {
|
||||
errors.push('Missing region');
|
||||
}
|
||||
},
|
||||
|
||||
finally(hookContext: HookContext) {
|
||||
finalErrors = (hookContext.hookData.get('errors') as string[]) || [];
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(validationHook);
|
||||
client.getBooleanValue('test-flag', false, {});
|
||||
|
||||
expect(finalErrors).toContain('Missing userId');
|
||||
expect(finalErrors).toContain('Missing region');
|
||||
});
|
||||
|
||||
it('should support request correlation', () => {
|
||||
let correlationId: string | undefined;
|
||||
|
||||
const correlationHook: Hook = {
|
||||
before(hookContext: BeforeHookContext) {
|
||||
const id = `req-${Date.now()}-${Math.random()}`;
|
||||
hookContext.hookData.set('correlationId', id);
|
||||
},
|
||||
|
||||
after(hookContext: HookContext) {
|
||||
correlationId = hookContext.hookData.get('correlationId') as string;
|
||||
},
|
||||
};
|
||||
|
||||
client.addHooks(correlationHook);
|
||||
client.getBooleanValue('test-flag', false);
|
||||
|
||||
expect(correlationId).toBeDefined();
|
||||
expect(correlationId).toMatch(/^req-\d+-[\d.]+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue