diff --git a/libs/providers/ofrep-web/src/lib/model/in-memory-cache.ts b/libs/providers/ofrep-web/src/lib/model/in-memory-cache.ts index 8015c3510..9956b5eec 100644 --- a/libs/providers/ofrep-web/src/lib/model/in-memory-cache.ts +++ b/libs/providers/ofrep-web/src/lib/model/in-memory-cache.ts @@ -1,7 +1,12 @@ -import { FlagValue, ResolutionDetails } from '@openfeature/web-sdk'; +import type { FlagMetadata, FlagValue, ResolutionDetails } from '@openfeature/web-sdk'; import { ResolutionError } from './resolution-error'; /** - * FlagCache is a type representing the internal cache of the flags. + * Cache of flag values from bulk evaluation. */ export type FlagCache = { [key: string]: ResolutionDetails | ResolutionError }; + +/** + * Cache of metadata from bulk evaluation. + */ +export type MetadataCache = FlagMetadata; diff --git a/libs/providers/ofrep-web/src/lib/model/resolution-error.ts b/libs/providers/ofrep-web/src/lib/model/resolution-error.ts index bef902435..5fe75ab67 100644 --- a/libs/providers/ofrep-web/src/lib/model/resolution-error.ts +++ b/libs/providers/ofrep-web/src/lib/model/resolution-error.ts @@ -1,9 +1,8 @@ -import { ResolutionReason } from '@openfeature/web-sdk'; -import { EvaluationFailureErrorCode } from '@openfeature/ofrep-core'; +import { ErrorCode, ResolutionReason } from '@openfeature/web-sdk'; export type ResolutionError = { reason: ResolutionReason; - errorCode: EvaluationFailureErrorCode; + errorCode: ErrorCode; errorDetails?: string; }; diff --git a/libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts b/libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts index f7188919c..69a242f9e 100644 --- a/libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts +++ b/libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts @@ -3,6 +3,8 @@ import TestLogger from '../../test/test-logger'; // eslint-disable-next-line @nx/enforce-module-boundaries import { server } from '../../../../shared/ofrep-core/src/test/mock-service-worker'; import { ClientProviderEvents, ClientProviderStatus, OpenFeature } from '@openfeature/web-sdk'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from '../../../../shared/ofrep-core/src/test/test-constants'; describe('OFREPWebProvider', () => { beforeAll(() => server.listen()); @@ -144,7 +146,7 @@ describe('OFREPWebProvider', () => { expect(client.providerStatus).toBe(ClientProviderStatus.ERROR); }); - it('should return a FLAG_NOT_FOUND error if the flag does not exist', async () => { + it('should return a FLAG_NOT_FOUND error and flag set metadata if the flag does not exist', async () => { const providerName = expect.getState().currentTestName || 'test-provider'; const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL }, new TestLogger()); await OpenFeature.setContext(defaultContext); @@ -154,6 +156,7 @@ describe('OFREPWebProvider', () => { const flag = client.getBooleanDetails('non-existent-flag', false); expect(flag.errorCode).toBe('FLAG_NOT_FOUND'); expect(flag.value).toBe(false); + expect(flag.flagMetadata).toEqual(TEST_FLAG_SET_METADATA); }); it('should return EvaluationDetails if the flag exists', async () => { @@ -169,7 +172,7 @@ describe('OFREPWebProvider', () => { flagKey, value: true, variant: 'variantA', - flagMetadata: { context: defaultContext }, + flagMetadata: TEST_FLAG_METADATA, reason: 'STATIC', }); }); @@ -187,7 +190,7 @@ describe('OFREPWebProvider', () => { flagKey, value: false, errorCode: 'PARSE_ERROR', - errorMessage: 'parse error for flag key parse-error: custom error details', + errorMessage: 'Flag or flag configuration could not be parsed', reason: 'ERROR', flagMetadata: {}, }); diff --git a/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts b/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts index 8b8769c44..42cc57be5 100644 --- a/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts +++ b/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts @@ -1,5 +1,4 @@ import { - EvaluationFailureErrorCode, EvaluationRequest, EvaluationResponse, OFREPApi, @@ -7,36 +6,41 @@ import { OFREPApiTooManyRequestsError, OFREPApiUnauthorizedError, OFREPForbiddenError, - handleEvaluationError, isEvaluationFailureResponse, isEvaluationSuccessResponse, } from '@openfeature/ofrep-core'; import { ClientProviderEvents, + ErrorCode, EvaluationContext, - FlagMetadata, - FlagNotFoundError, FlagValue, GeneralError, Hook, - InvalidContextError, JsonValue, Logger, OpenFeatureError, OpenFeatureEventEmitter, - ParseError, Provider, ProviderFatalError, ResolutionDetails, StandardResolutionReasons, - TargetingKeyMissingError, - TypeMismatchError, } from '@openfeature/web-sdk'; import { BulkEvaluationStatus, EvaluateFlagsResponse } from './model/evaluate-flags-response'; -import { FlagCache } from './model/in-memory-cache'; +import { FlagCache, MetadataCache } from './model/in-memory-cache'; import { OFREPWebProviderOptions } from './model/ofrep-web-provider-options'; import { isResolutionError } from './model/resolution-error'; +const ErrorMessageMap: { [key in ErrorCode]: string } = { + [ErrorCode.FLAG_NOT_FOUND]: 'Flag was not found', + [ErrorCode.GENERAL]: 'General error', + [ErrorCode.INVALID_CONTEXT]: 'Context is invalid or could be parsed', + [ErrorCode.PARSE_ERROR]: 'Flag or flag configuration could not be parsed', + [ErrorCode.PROVIDER_FATAL]: 'Provider is in a fatal error state', + [ErrorCode.PROVIDER_NOT_READY]: 'Provider is not yet ready', + [ErrorCode.TARGETING_KEY_MISSING]: 'Targeting key is missing', + [ErrorCode.TYPE_MISMATCH]: 'Flag is not of expected type', +}; + export class OFREPWebProvider implements Provider { DEFAULT_POLL_INTERVAL = 30000; @@ -52,10 +56,11 @@ export class OFREPWebProvider implements Provider { // _options is the options used to configure the provider. private _options: OFREPWebProviderOptions; private _ofrepAPI: OFREPApi; - private _etag: string | null; + private _etag: string | null | undefined; private _pollingInterval: number; private _retryPollingAfter: Date | undefined; private _flagCache: FlagCache = {}; + private _flagSetMetadataCache: MetadataCache = {}; private _context: EvaluationContext | undefined; private _pollingIntervalId?: number; @@ -81,7 +86,7 @@ export class OFREPWebProvider implements Provider { async initialize(context?: EvaluationContext | undefined): Promise { try { this._context = context; - await this._evaluateFlags(context); + await this._fetchFlags(context); if (this._pollingInterval > 0) { this.startPolling(); @@ -102,30 +107,29 @@ export class OFREPWebProvider implements Provider { defaultValue: boolean, context: EvaluationContext, ): ResolutionDetails { - return this.evaluate(flagKey, 'boolean'); + return this._resolve(flagKey, 'boolean', defaultValue); } resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, ): ResolutionDetails { - return this.evaluate(flagKey, 'string'); + return this._resolve(flagKey, 'string', defaultValue); } resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, ): ResolutionDetails { - return this.evaluate(flagKey, 'number'); + return this._resolve(flagKey, 'number', defaultValue); } resolveObjectEvaluation( flagKey: string, defaultValue: T, context: EvaluationContext, ): ResolutionDetails { - return this.evaluate(flagKey, 'object'); + return this._resolve(flagKey, 'object', defaultValue); } - /* eslint-enable @typescript-eslint/no-unused-vars */ /** * onContextChange is called when the context changes, it will re-evaluate the flags with the new context @@ -143,7 +147,7 @@ export class OFREPWebProvider implements Provider { return; } - await this._evaluateFlags(newContext); + await this._fetchFlags(newContext); } catch (error) { if (error instanceof OFREPApiTooManyRequestsError) { this.events?.emit(ClientProviderEvents.Stale, { message: `${error.name}: ${error.message}` }); @@ -172,7 +176,7 @@ export class OFREPWebProvider implements Provider { } /** - * _evaluateFlags is a function that will call the bulk evaluate flags endpoint to get the flags values. + * _fetchFlags is a function that will call the bulk evaluate flags endpoint to get the flags values. * @param context - the context to use for the evaluation * @private * @returns EvaluationStatus if the evaluation the API returned a 304, 200. @@ -181,7 +185,7 @@ export class OFREPWebProvider implements Provider { * @throws ParseError if the API returned a 400 with the error code ParseError * @throws GeneralError if the API returned a 400 with an unknown error code */ - private async _evaluateFlags(context?: EvaluationContext | undefined): Promise { + private async _fetchFlags(context?: EvaluationContext | undefined): Promise { try { const evalReq: EvaluationRequest = { context, @@ -194,34 +198,40 @@ export class OFREPWebProvider implements Provider { } if (response.httpStatus !== 200) { - handleEvaluationError(response); + throw new GeneralError(`Failed OFREP bulk evaluation request, status: ${response.httpStatus}`); } const bulkSuccessResp = response.value; const newCache: FlagCache = {}; - bulkSuccessResp.flags?.forEach((evalResp: EvaluationResponse) => { - if (isEvaluationFailureResponse(evalResp)) { - newCache[evalResp.key] = { - errorCode: evalResp.errorCode, - errorDetails: evalResp.errorDetails, - reason: StandardResolutionReasons.ERROR, - }; - } + if ('flags' in bulkSuccessResp && Array.isArray(bulkSuccessResp.flags)) { + bulkSuccessResp.flags.forEach((evalResp: EvaluationResponse) => { + if (isEvaluationFailureResponse(evalResp)) { + newCache[evalResp.key] = { + reason: StandardResolutionReasons.ERROR, + flagMetadata: evalResp.metadata, + errorCode: evalResp.errorCode, + errorDetails: evalResp.errorDetails, + }; + } - if (isEvaluationSuccessResponse(evalResp) && evalResp.key) { - newCache[evalResp.key] = { - value: evalResp.value, - flagMetadata: evalResp.metadata as FlagMetadata, - reason: evalResp.reason, - variant: evalResp.variant, - }; - } - }); - const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache); - this._flagCache = newCache; - this._etag = response.httpResponse?.headers.get('etag'); - return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags }; + if (isEvaluationSuccessResponse(evalResp) && evalResp.key) { + newCache[evalResp.key] = { + value: evalResp.value, + variant: evalResp.variant, + reason: evalResp.reason, + flagMetadata: evalResp.metadata, + }; + } + }); + const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache); + this._flagCache = newCache; + this._etag = response.httpResponse?.headers.get('etag'); + this._flagSetMetadataCache = typeof bulkSuccessResp.metadata === 'object' ? bulkSuccessResp.metadata : {}; + return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags }; + } else { + throw new Error('No flags in OFREP bulk evaluation response'); + } } catch (error) { if (error instanceof OFREPApiTooManyRequestsError && error.retryAfterDate !== null) { this._retryPollingAfter = error.retryAfterDate; @@ -260,37 +270,41 @@ export class OFREPWebProvider implements Provider { } /** - * Evaluate is a function retrieving the value from a flag in the cache. + * _resolve is a function retrieving the value from a flag in the cache. * @param flagKey - name of the flag to retrieve * @param type - type of the flag + * @param defaultValue - default value * @private */ - private evaluate(flagKey: string, type: string): ResolutionDetails { + private _resolve(flagKey: string, type: string, defaultValue: T): ResolutionDetails { const resolved = this._flagCache[flagKey]; + if (!resolved) { - throw new FlagNotFoundError(`flag key ${flagKey} not found in cache`); + return { + value: defaultValue, + flagMetadata: this._flagSetMetadataCache, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: ErrorMessageMap[ErrorCode.FLAG_NOT_FOUND], + }; } if (isResolutionError(resolved)) { - switch (resolved.errorCode) { - case EvaluationFailureErrorCode.FlagNotFound: - throw new FlagNotFoundError(`flag key ${flagKey} not found: ${resolved.errorDetails}`); - case EvaluationFailureErrorCode.TargetingKeyMissing: - throw new TargetingKeyMissingError(`targeting key missing for flag key ${flagKey}: ${resolved.errorDetails}`); - case EvaluationFailureErrorCode.InvalidContext: - throw new InvalidContextError(`invalid context for flag key ${flagKey}: ${resolved.errorDetails}`); - case EvaluationFailureErrorCode.ParseError: - throw new ParseError(`parse error for flag key ${flagKey}: ${resolved.errorDetails}`); - case EvaluationFailureErrorCode.General: - default: - throw new GeneralError( - `general error during flag evaluation for flag key ${flagKey}: ${resolved.errorDetails}`, - ); - } + return { + ...resolved, + value: defaultValue, + errorMessage: ErrorMessageMap[resolved.errorCode], + }; } if (typeof resolved.value !== type) { - throw new TypeMismatchError(`flag key ${flagKey} is not of type ${type}`); + return { + value: defaultValue, + flagMetadata: resolved.flagMetadata, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: ErrorMessageMap[ErrorCode.TYPE_MISMATCH], + }; } return { @@ -314,7 +328,7 @@ export class OFREPWebProvider implements Provider { if (this._retryPollingAfter !== undefined && this._retryPollingAfter > now) { return; } - const res = await this._evaluateFlags(this._context); + const res = await this._fetchFlags(this._context); if (res.status === BulkEvaluationStatus.SUCCESS_WITH_CHANGES) { this.events?.emit(ClientProviderEvents.ConfigurationChanged, { message: 'Flags updated', diff --git a/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts b/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts index 2abb5738e..83da340a3 100644 --- a/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts +++ b/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts @@ -1,21 +1,15 @@ import { OFREPProvider, OFREPProviderOptions } from './ofrep-provider'; - -// eslint-disable-next-line @nx/enforce-module-boundaries -import { server } from '../../../../shared/ofrep-core/src/test/mock-service-worker'; import { OFREPApiTooManyRequestsError, OFREPApiUnauthorizedError, OFREPApiUnexpectedResponseError, OFREPForbiddenError, } from '@openfeature/ofrep-core'; -import { - FlagNotFoundError, - GeneralError, - InvalidContextError, - ParseError, - TargetingKeyMissingError, - TypeMismatchError, -} from '@openfeature/server-sdk'; +import { ErrorCode, GeneralError, TypeMismatchError } from '@openfeature/server-sdk'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { TEST_FLAG_METADATA } from '../../../../shared/ofrep-core/src/test/test-constants'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { server } from '../../../../shared/ofrep-core/src/test/mock-service-worker'; describe('OFREPProvider should', () => { let provider: OFREPProvider; @@ -106,45 +100,40 @@ describe('OFREPProvider should', () => { // Now the time is over and the provider should call the API again jest.setSystemTime(new Date('2018-01-27T00:33:21.000Z')); expect(await fastProvider.resolveBooleanEvaluation('my-flag', false, {})).toEqual({ - flagMetadata: {}, + flagMetadata: TEST_FLAG_METADATA, reason: 'STATIC', value: true, variant: 'default', }); }); - it('map EvaluationFailureErrorCode.ParseError from response to ParseError', async () => { - await expect(provider.resolveBooleanEvaluation('my-flag', false, { errors: { parseError: true } })).rejects.toThrow( - ParseError, - ); + it.each([ + { errorIndex: 'parseError', errCode: ErrorCode.PARSE_ERROR }, + { errorIndex: 'targetingMissing', errCode: ErrorCode.TARGETING_KEY_MISSING }, + { errorIndex: 'invalidContext', errCode: ErrorCode.INVALID_CONTEXT }, + { errorIndex: 'notFound', errCode: ErrorCode.FLAG_NOT_FOUND }, + { errorIndex: 'general', errCode: ErrorCode.GENERAL }, + ])('maps error index to code', async (args) => { + const resolved = await provider.resolveBooleanEvaluation('my-flag', false, { errors: { [args.errorIndex]: true } }); + expect(resolved.errorCode).toEqual(args.errCode); }); - it('map EvaluationFailureErrorCode.TargetingKeyMissingError from response to TargetingKeyMissingError', async () => { - await expect( - provider.resolveBooleanEvaluation('my-flag', false, { errors: { targetingMissing: true } }), - ).rejects.toThrow(TargetingKeyMissingError); + it('should return metadata on error body', async () => { + const flag = await provider.resolveBooleanEvaluation('my-flag', true, { errors: { notFound: true } }); + expect(flag.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); + expect(flag.flagMetadata).toEqual(TEST_FLAG_METADATA); }); - it('map EvaluationFailureErrorCode.InvalidContext from response to InvalidContextError', async () => { - await expect( - provider.resolveBooleanEvaluation('my-flag', false, { errors: { invalidContext: true } }), - ).rejects.toThrow(InvalidContextError); + it('should return metadata on http error', async () => { + const flag = await provider.resolveBooleanEvaluation('my-flag', true, { errors: { metadata404: true } }); + expect(flag.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); + expect(flag.flagMetadata).toEqual(TEST_FLAG_METADATA); }); - it('map EvaluationFailureErrorCode.FlagNotFound from response to FlagNotFoundError', async () => { - await expect(provider.resolveBooleanEvaluation('my-flag', false, { errors: { notFound: true } })).rejects.toThrow( - FlagNotFoundError, - ); - }); - - it('map EvaluationFailureErrorCode.General from response to General', async () => { - await expect(provider.resolveBooleanEvaluation('my-flag', false, { errors: { general: true } })).rejects.toThrow( - GeneralError, - ); - }); - - it('throw TypeMismatchError if response type is different rom requested one', async () => { - await expect(provider.resolveNumberEvaluation('my-flag', 42, {})).rejects.toThrow(TypeMismatchError); + it('return TypeMismatchError if response type is different rom requested one', async () => { + const flag = await provider.resolveNumberEvaluation('my-flag', 42, {}); + expect(flag.errorCode).toEqual(ErrorCode.TYPE_MISMATCH); + expect(flag.flagMetadata).toEqual(TEST_FLAG_METADATA); }); it('send auth header from headerFactory', async () => { @@ -186,6 +175,11 @@ describe('OFREPProvider should', () => { targetingKey: 'user1', customValue: 'custom', }); - expect(flag).toEqual({ flagMetadata: {}, reason: 'TARGETING_MATCH', value: true, variant: 'default' }); + expect(flag).toEqual({ + flagMetadata: TEST_FLAG_METADATA, + reason: 'TARGETING_MATCH', + value: true, + variant: 'default', + }); }); }); diff --git a/libs/providers/ofrep/src/lib/ofrep-provider.ts b/libs/providers/ofrep/src/lib/ofrep-provider.ts index 67024f84a..c1ed58ed5 100644 --- a/libs/providers/ofrep/src/lib/ofrep-provider.ts +++ b/libs/providers/ofrep/src/lib/ofrep-provider.ts @@ -8,12 +8,13 @@ import { toResolutionDetails, } from '@openfeature/ofrep-core'; import { + ErrorCode, EvaluationContext, GeneralError, JsonValue, Provider, ResolutionDetails, - TypeMismatchError, + StandardResolutionReasons, } from '@openfeature/server-sdk'; export type OFREPProviderOptions = OFREPProviderBaseOptions; @@ -84,25 +85,32 @@ export class OFREPProvider implements Provider { try { const result = await this.ofrepApi.postEvaluateFlag(flagKey, { context }); - return this.toResolutionDetails(result, defaultValue); + return this.responseToResolutionDetails(result, defaultValue); } catch (error) { - if (error instanceof OFREPApiTooManyRequestsError) { - this.notBefore = error.retryAfterDate; - } - throw error; + return handleEvaluationError(error as Error, defaultValue, (resultOrError) => { + if (resultOrError instanceof OFREPApiTooManyRequestsError) { + this.notBefore = resultOrError.retryAfterDate; + } + }); } } - private toResolutionDetails( + private responseToResolutionDetails( result: OFREPApiEvaluationResult, defaultValue: T, ): ResolutionDetails { if (result.httpStatus !== 200) { - handleEvaluationError(result); + return handleEvaluationError(result, defaultValue); } if (typeof result.value.value !== typeof defaultValue) { - throw new TypeMismatchError(`Expected flag type ${typeof defaultValue} but got ${typeof result.value.value}`); + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: result.value.metadata, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'Flag is not of expected type', + }; } return toResolutionDetails(result.value); diff --git a/libs/shared/ofrep-core/src/lib/api/ofrep-api.spec.ts b/libs/shared/ofrep-core/src/lib/api/ofrep-api.spec.ts index e4c0ef829..e26fb20d6 100644 --- a/libs/shared/ofrep-core/src/lib/api/ofrep-api.spec.ts +++ b/libs/shared/ofrep-core/src/lib/api/ofrep-api.spec.ts @@ -1,14 +1,12 @@ +import { ErrorCode, StandardResolutionReasons } from '@openfeature/core'; import { server } from '../../test/mock-service-worker'; -import { OFREPApi } from './ofrep-api'; +import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from '../../test/test-constants'; import { BulkEvaluationFailureResponse, BulkEvaluationSuccessResponse, - EvaluationFailureErrorCode, EvaluationFailureResponse, - EvaluationSuccessReason, EvaluationSuccessResponse, } from '../model'; -import { EvaluationContext } from '@openfeature/core'; import { OFREPApiFetchError, OFREPApiTooManyRequestsError, @@ -16,6 +14,7 @@ import { OFREPApiUnexpectedResponseError, OFREPForbiddenError, } from './errors'; +import { OFREPApi } from './ofrep-api'; describe('OFREPApi', () => { let api: OFREPApi; @@ -149,12 +148,7 @@ describe('OFREPApi', () => { throw new Error('Received unexpected HTTP status'); } - expect(result.value.metadata).toEqual({ - context: { - key1: 'value1', - targetingKey: 'user-1', - }, - } satisfies EvaluationContext); + expect(result.value.metadata).toEqual(TEST_FLAG_METADATA); }); it('return HTTP status in result', async () => { @@ -170,7 +164,8 @@ describe('OFREPApi', () => { expect(result.value).toEqual({ key: 'my-flag', - errorCode: EvaluationFailureErrorCode.FlagNotFound, + errorCode: ErrorCode.FLAG_NOT_FOUND, + metadata: TEST_FLAG_METADATA, } satisfies EvaluationFailureResponse); }); @@ -182,7 +177,8 @@ describe('OFREPApi', () => { expect(result.value).toEqual({ key: 'my-flag', - errorCode: EvaluationFailureErrorCode.FlagNotFound, + errorCode: ErrorCode.FLAG_NOT_FOUND, + metadata: TEST_FLAG_METADATA, } satisfies EvaluationFailureResponse); }); @@ -203,14 +199,10 @@ describe('OFREPApi', () => { expect(result.httpStatus).toEqual(200); expect(result.value).toEqual({ key: 'my-flag', - reason: EvaluationSuccessReason.TargetingMatch, + reason: StandardResolutionReasons.TARGETING_MATCH, value: true, variant: 'default', - metadata: { - context: { - targetingKey: 'user', - }, - }, + metadata: TEST_FLAG_METADATA, } satisfies EvaluationSuccessResponse); }); @@ -220,14 +212,10 @@ describe('OFREPApi', () => { expect(result.httpStatus).toEqual(200); expect(result.value).toEqual({ key: 'my-flag', - reason: EvaluationSuccessReason.TargetingMatch, + reason: StandardResolutionReasons.TARGETING_MATCH, value: true, variant: 'scoped', - metadata: { - context: { - targetingKey: 'user', - }, - }, + metadata: TEST_FLAG_METADATA, } satisfies EvaluationSuccessResponse); }); }); @@ -281,22 +269,18 @@ describe('OFREPApi', () => { } expect(result.value).toEqual({ + metadata: TEST_FLAG_SET_METADATA, flags: [ { key: 'bool-flag', - metadata: { context: { key1: 'value1', targetingKey: 'user-1' } }, + metadata: TEST_FLAG_METADATA, value: true, - reason: EvaluationSuccessReason.Static, + reason: StandardResolutionReasons.STATIC, variant: 'variantA', }, { key: 'object-flag', - metadata: { - context: { - key1: 'value1', - targetingKey: 'user-1', - }, - }, + metadata: TEST_FLAG_METADATA, value: { complex: true, nested: { @@ -320,7 +304,7 @@ describe('OFREPApi', () => { } expect(result.value).toEqual({ - errorCode: EvaluationFailureErrorCode.TargetingKeyMissing, + errorCode: ErrorCode.TARGETING_KEY_MISSING, } satisfies BulkEvaluationFailureResponse); }); @@ -350,6 +334,7 @@ describe('OFREPApi', () => { const result = await api.postBulkEvaluateFlags(); expect(result.httpStatus).toEqual(200); expect(result.value).toEqual({ + metadata: TEST_FLAG_SET_METADATA, flags: [ { key: 'other-flag', @@ -363,17 +348,18 @@ describe('OFREPApi', () => { const result = await api.postBulkEvaluateFlags(); expect(result.httpStatus).toEqual(200); expect(result.value).toEqual({ + metadata: TEST_FLAG_SET_METADATA, flags: [ { key: 'bool-flag', - metadata: {}, + metadata: TEST_FLAG_METADATA, value: true, - reason: EvaluationSuccessReason.Static, + reason: StandardResolutionReasons.STATIC, variant: 'variantA', }, { key: 'object-flag', - metadata: {}, + metadata: TEST_FLAG_METADATA, value: { complex: true, nested: { diff --git a/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts b/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts index 1d5ab72cb..b2fdb78b6 100644 --- a/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts +++ b/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts @@ -1,20 +1,10 @@ +import { ErrorCode, FlagMetadata, ResolutionDetails, StandardResolutionReasons } from '@openfeature/core'; import { - FlagMetadata, - FlagNotFoundError, - GeneralError, - InvalidContextError, - ParseError, - ResolutionDetails, - TargetingKeyMissingError, -} from '@openfeature/core'; -import { - EvaluationFailureErrorCode, EvaluationFlagValue, EvaluationRequest, EvaluationSuccessResponse, OFREPApiBulkEvaluationFailureResult, OFREPApiBulkEvaluationResult, - OFREPApiEvaluationFailureResult, OFREPApiEvaluationResult, OFREPEvaluationErrorHttpStatus, OFREPEvaluationErrorHttpStatuses, @@ -165,25 +155,33 @@ export class OFREPApi { } } -export function handleEvaluationError( - result: OFREPApiEvaluationFailureResult | OFREPApiBulkEvaluationFailureResult, -): never { - const code = result.value.errorCode; - const details = result.value.errorDetails; - - switch (code) { - case EvaluationFailureErrorCode.ParseError: - throw new ParseError(details); - case EvaluationFailureErrorCode.TargetingKeyMissing: - throw new TargetingKeyMissingError(details); - case EvaluationFailureErrorCode.InvalidContext: - throw new InvalidContextError(details); - case EvaluationFailureErrorCode.FlagNotFound: - throw new FlagNotFoundError(details); - case EvaluationFailureErrorCode.General: - throw new GeneralError(details); - default: - throw new GeneralError(details); +export function handleEvaluationError( + resultOrError: OFREPApiBulkEvaluationFailureResult | Error, + defaultValue: T, + callback?: (resultOrError: OFREPApiBulkEvaluationFailureResult | Error) => void, +): ResolutionDetails { + callback?.(resultOrError); + + if ('value' in resultOrError) { + const code = resultOrError.value.errorCode || ErrorCode.GENERAL; + const message = resultOrError.value.errorCode; + const metadata = resultOrError.value.metadata; + + const resolution: ResolutionDetails = { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: metadata, + errorCode: code, + errorMessage: message, + }; + + return resolution; + } else { + if (resultOrError instanceof Error) { + throw resultOrError; + } else { + throw new Error('OFREP flag evaluation error', { cause: resultOrError }); + } } } diff --git a/libs/shared/ofrep-core/src/lib/model/bulk-evaluation.ts b/libs/shared/ofrep-core/src/lib/model/bulk-evaluation.ts index e6b0db0ce..acf63633b 100644 --- a/libs/shared/ofrep-core/src/lib/model/bulk-evaluation.ts +++ b/libs/shared/ofrep-core/src/lib/model/bulk-evaluation.ts @@ -1,10 +1,11 @@ -import { EvaluationFailureErrorCode, EvaluationResponse } from './evaluation'; +import { ErrorCode } from '@openfeature/core'; +import { EvaluationResponse, MetadataResponse } from './evaluation'; -export interface BulkEvaluationFailureResponse { +export interface BulkEvaluationFailureResponse extends MetadataResponse { /** * An appropriate code specific to the bulk evaluation error. See https://openfeature.dev/specification/types#error-code */ - errorCode: EvaluationFailureErrorCode; + errorCode: ErrorCode; /** * Optional error details description for logging or other needs */ @@ -19,11 +20,7 @@ export function isBulkEvaluationFailureResponse(response: unknown): response is return 'errorCode' in response; } -export interface BulkEvaluationSuccessResponse { - flags?: EvaluationResponse[]; -} - -export interface BulkEvaluationSuccessResponse { +export interface BulkEvaluationSuccessResponse extends MetadataResponse { flags?: EvaluationResponse[]; } diff --git a/libs/shared/ofrep-core/src/lib/model/evaluation.ts b/libs/shared/ofrep-core/src/lib/model/evaluation.ts index 87be04478..e2be7ac53 100644 --- a/libs/shared/ofrep-core/src/lib/model/evaluation.ts +++ b/libs/shared/ofrep-core/src/lib/model/evaluation.ts @@ -1,4 +1,4 @@ -import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import type { ErrorCode, EvaluationContext, FlagMetadata, FlagValue, ResolutionReason } from '@openfeature/core'; export interface EvaluationRequest { /** @@ -7,17 +7,16 @@ export interface EvaluationRequest { context?: EvaluationContext; } -export enum EvaluationSuccessReason { - Static = 'STATIC', - TargetingMatch = 'TARGETING_MATCH', - Split = 'SPLIT', - Disabled = 'DISABLED', - Unknown = 'UNKNOWN', -} - export type EvaluationFlagValue = FlagValue; -export interface EvaluationSuccessResponse { +export interface MetadataResponse { + /** + * Arbitrary metadata for the flag, useful for telemetry and documentary purposes + */ + metadata?: FlagMetadata; +} + +export interface EvaluationSuccessResponse extends MetadataResponse { /** * Feature flag key */ @@ -25,15 +24,11 @@ export interface EvaluationSuccessResponse { /** * An OpenFeature reason for the evaluation */ - reason?: EvaluationSuccessReason; + reason?: ResolutionReason; /** * Variant of the evaluated flag value */ variant?: string; - /** - * Arbitrary metadata supporting flag evaluation - */ - metadata?: object; /** * Flag evaluation result */ @@ -48,15 +43,7 @@ export function isEvaluationSuccessResponse(response: unknown): response is Eval return 'value' in response; } -export enum EvaluationFailureErrorCode { - ParseError = 'PARSE_ERROR', - TargetingKeyMissing = 'TARGETING_KEY_MISSING', - InvalidContext = 'INVALID_CONTEXT', - General = 'GENERAL', - FlagNotFound = 'FLAG_NOT_FOUND', -} - -export interface EvaluationFailureResponse { +export interface EvaluationFailureResponse extends MetadataResponse { /** * Feature flag key */ @@ -64,7 +51,7 @@ export interface EvaluationFailureResponse { /** * OpenFeature compatible error code. See https://openfeature.dev/specification/types#error-code */ - errorCode: EvaluationFailureErrorCode; + errorCode: ErrorCode; /** * An error description for logging or other needs */ diff --git a/libs/shared/ofrep-core/src/test/handlers.ts b/libs/shared/ofrep-core/src/test/handlers.ts index c21a007ee..7ac633bb7 100644 --- a/libs/shared/ofrep-core/src/test/handlers.ts +++ b/libs/shared/ofrep-core/src/test/handlers.ts @@ -1,12 +1,7 @@ import { http, HttpResponse, StrictResponse } from 'msw'; -import { - BulkEvaluationResponse, - EvaluationFailureErrorCode, - EvaluationFailureResponse, - EvaluationRequest, - EvaluationResponse, - EvaluationSuccessReason, -} from '../lib'; +import { BulkEvaluationResponse, EvaluationFailureResponse, EvaluationRequest, EvaluationResponse } from '../lib'; +import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from './test-constants'; +import { ErrorCode, StandardResolutionReasons } from '@openfeature/core'; export const handlers = [ http.post<{ key: string }, EvaluationRequest, EvaluationResponse>( @@ -36,30 +31,45 @@ export const handlers = [ } if (errors?.['generic400']) { - throw HttpResponse.text(undefined, { status: 400 }); + throw HttpResponse.json({ metadata: TEST_FLAG_METADATA }, { status: 400 }); } if (errors?.['401'] || expectedAuthHeader !== authHeader) { - throw HttpResponse.text(undefined, { status: 401 }); + throw HttpResponse.json({ metadata: TEST_FLAG_METADATA }, { status: 401 }); } if (errors?.['403']) { - throw HttpResponse.text(undefined, { status: 403 }); + throw HttpResponse.json({ metadata: TEST_FLAG_METADATA }, { status: 403 }); + } + + if (errors?.['metadata404']) { + throw HttpResponse.json( + { + key: info.params.key, + errorCode: ErrorCode.FLAG_NOT_FOUND, + metadata: TEST_FLAG_METADATA, + }, + { status: 404 }, + ); } if (errors?.['429'] === true) { - throw HttpResponse.text(undefined, { status: 429, headers: { 'Retry-After': '2000' } }); + throw HttpResponse.json({ metadata: TEST_FLAG_METADATA }, { status: 429, headers: { 'Retry-After': '2000' } }); } if (typeof errors?.['429'] === 'string') { - throw HttpResponse.text(undefined, { status: 429, headers: { 'Retry-After': errors?.['429'] } }); + throw HttpResponse.json( + { metadata: TEST_FLAG_METADATA }, + { status: 429, headers: { 'Retry-After': errors?.['429'] } }, + ); } if (errors?.['parseError']) { return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.ParseError, + errorCode: ErrorCode.PARSE_ERROR, + metadata: TEST_FLAG_METADATA, }, { status: 400 }, ); @@ -69,7 +79,8 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.TargetingKeyMissing, + errorCode: ErrorCode.TARGETING_KEY_MISSING, + metadata: TEST_FLAG_METADATA, }, { status: 400 }, ); @@ -79,7 +90,8 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.InvalidContext, + errorCode: ErrorCode.INVALID_CONTEXT, + metadata: TEST_FLAG_METADATA, }, { status: 400 }, ); @@ -89,7 +101,8 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.FlagNotFound, + errorCode: ErrorCode.FLAG_NOT_FOUND, + metadata: TEST_FLAG_METADATA, }, { status: 404 }, ); @@ -99,7 +112,8 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.General, + errorCode: ErrorCode.GENERAL, + metadata: TEST_FLAG_METADATA, }, { status: 400 }, ); @@ -110,11 +124,11 @@ export const handlers = [ return HttpResponse.json({ key: info.params.key, reason: requestBody.context?.targetingKey - ? EvaluationSuccessReason.TargetingMatch - : EvaluationSuccessReason.Static, + ? StandardResolutionReasons.TARGETING_MATCH + : StandardResolutionReasons.STATIC, variant: scopeValue ? 'scoped' : 'default', value: true, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, }); }, ), @@ -165,7 +179,7 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.ParseError, + errorCode: ErrorCode.PARSE_ERROR, }, { status: 400 }, ); @@ -175,7 +189,7 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.TargetingKeyMissing, + errorCode: ErrorCode.TARGETING_KEY_MISSING, }, { status: 400 }, ); @@ -185,7 +199,7 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.InvalidContext, + errorCode: ErrorCode.INVALID_CONTEXT, }, { status: 400 }, ); @@ -195,7 +209,7 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.FlagNotFound, + errorCode: ErrorCode.FLAG_NOT_FOUND, }, { status: 404 }, ); @@ -205,7 +219,7 @@ export const handlers = [ return HttpResponse.json( { key: info.params.key, - errorCode: EvaluationFailureErrorCode.General, + errorCode: ErrorCode.GENERAL, }, { status: 400 }, ); @@ -218,43 +232,43 @@ export const handlers = [ { key: 'bool-flag', value: true, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, variant: 'variantA', - reason: EvaluationSuccessReason.Static, + reason: StandardResolutionReasons.STATIC, }, { key: 'parse-error', - errorCode: EvaluationFailureErrorCode.ParseError, + errorCode: ErrorCode.PARSE_ERROR, errorDetails: 'custom error details', }, { key: 'flag-not-found', - errorCode: EvaluationFailureErrorCode.FlagNotFound, + errorCode: ErrorCode.FLAG_NOT_FOUND, errorDetails: 'custom error details', }, { key: 'targeting-key-missing', - errorCode: EvaluationFailureErrorCode.TargetingKeyMissing, + errorCode: ErrorCode.TARGETING_KEY_MISSING, errorDetails: 'custom error details', }, { key: 'targeting-key-missing', - errorCode: EvaluationFailureErrorCode.TargetingKeyMissing, + errorCode: ErrorCode.TARGETING_KEY_MISSING, errorDetails: 'custom error details', }, { key: 'invalid-context', - errorCode: EvaluationFailureErrorCode.InvalidContext, + errorCode: ErrorCode.INVALID_CONTEXT, errorDetails: 'custom error details', }, { key: 'general-error', - errorCode: EvaluationFailureErrorCode.General, + errorCode: ErrorCode.GENERAL, errorDetails: 'custom error details', }, { key: 'unknown-error', - errorCode: 'UNKNOWN_ERROR' as EvaluationFailureErrorCode, + errorCode: 'UNKNOWN_ERROR' as ErrorCode, errorDetails: 'custom error details', }, ], @@ -272,14 +286,15 @@ export const handlers = [ { key: 'object-flag', value: { complex: true, nested: { also: true }, refreshed: true }, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, }, { key: 'object-flag-2', value: { complex: true, nested: { also: true } }, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, }, ], + metadata: TEST_FLAG_SET_METADATA, }, { headers: { etag: '1234' } }, ); @@ -292,14 +307,14 @@ export const handlers = [ { key: 'bool-flag', value: true, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, variant: 'variantA', - reason: EvaluationSuccessReason.Static, + reason: StandardResolutionReasons.STATIC, }, { key: 'object-flag', value: { complex: true, nested: { also: true }, contextChange: true }, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, }, ], }, @@ -315,6 +330,7 @@ export const handlers = [ return HttpResponse.json( { + metadata: TEST_FLAG_SET_METADATA, flags: scopeValue ? [ { @@ -326,14 +342,14 @@ export const handlers = [ { key: 'bool-flag', value: true, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, variant: 'variantA', - reason: EvaluationSuccessReason.Static, + reason: StandardResolutionReasons.STATIC, }, { key: 'object-flag', value: { complex: true, nested: { also: true } }, - metadata: { context: requestBody.context }, + metadata: TEST_FLAG_METADATA, }, ], }, diff --git a/libs/shared/ofrep-core/src/test/test-constants.ts b/libs/shared/ofrep-core/src/test/test-constants.ts new file mode 100644 index 000000000..8a47aa834 --- /dev/null +++ b/libs/shared/ofrep-core/src/test/test-constants.ts @@ -0,0 +1,13 @@ +import { FlagMetadata } from '@openfeature/core'; + +export const TEST_FLAG_METADATA: FlagMetadata = { + booleanKey: true, + stringKey: 'string', + numberKey: 1, +} as const; + +export const TEST_FLAG_SET_METADATA: FlagMetadata = { + flagSetBooleanKey: true, + flagSetStringKey: 'string', + flagSetNumberKey: 1, +} as const; diff --git a/package-lock.json b/package-lock.json index 8e9bc5afc..76aae80e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,8 +48,8 @@ "@nx/web": "20.3.1", "@nx/workspace": "20.3.1", "@openfeature/core": "^1.6.0", - "@openfeature/server-sdk": "^1.17.0", - "@openfeature/web-sdk": "^1.4.0", + "@openfeature/server-sdk": "^1.17.1", + "@openfeature/web-sdk": "^1.4.1", "@opentelemetry/sdk-metrics": "^1.15.0", "@swc-node/register": "~1.10.0", "@swc/cli": "~0.6.0", @@ -3597,30 +3597,33 @@ "dev": true }, "node_modules/@openfeature/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.6.0.tgz", - "integrity": "sha512-QYAtwdreZU9Mi/LXLRzXsUA7PhbtT7+UJfRBMIAy6MidZjMgIbNfoh6+MncXb3UocThn0OsYa8WLfWD9q43eCQ==", - "dev": true + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.7.0.tgz", + "integrity": "sha512-Qg0+KpkNRyiFzBXORaZa/4F+Ds577LNgakCtauy7Asn7U8lyK3PP1nZ8yZWMKsInCXa1IEn3JZ+xkkoUd0Weng==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@openfeature/server-sdk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.17.0.tgz", - "integrity": "sha512-M5Dcw6/IROlvIVPzzgPEpq5JhbIGyGY7oVlN6cJMd9EbhJtQzmMQBuXKCqoar59OgQXKH/u2LQxEhS1ccaR/RA==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.17.1.tgz", + "integrity": "sha512-z5MaVvSNnk1SpRSuU02usnoX9rY9BtnLBNp9T08JOitwiuXs4byR8R5Es4WpsGRnnzBSoBQJL1iGIIYEeeEyxw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18" }, "peerDependencies": { - "@openfeature/core": "^1.6.0" + "@openfeature/core": "^1.7.0" } }, "node_modules/@openfeature/web-sdk": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.4.0.tgz", - "integrity": "sha512-cMCt5jszLiZ9mLacS7XjMTEpbIS3asttSpyrPJ8rAdwDk86UjzfPwzMTSiccVolJqS299hWGXC1FGbu4IHX40Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-1.4.1.tgz", + "integrity": "sha512-MgM55G/Ps8dNDsT75Yh6TYbp8XUVk0TdqSGSuo+6D9s4GyDzYHZHkcepp+j76Vvl4WwZPobdhADocUmUGskL1w==", "dev": true, + "license": "Apache-2.0", "peerDependencies": { - "@openfeature/core": "^1.6.0" + "@openfeature/core": "^1.7.0" } }, "node_modules/@opentelemetry/api": { diff --git a/package.json b/package.json index 66881ac78..b5e3b00b5 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "@nx/web": "20.3.1", "@nx/workspace": "20.3.1", "@openfeature/core": "^1.6.0", - "@openfeature/server-sdk": "^1.17.0", - "@openfeature/web-sdk": "^1.4.0", + "@openfeature/server-sdk": "^1.17.1", + "@openfeature/web-sdk": "^1.4.1", "@opentelemetry/sdk-metrics": "^1.15.0", "@swc-node/register": "~1.10.0", "@swc/cli": "~0.6.0", diff --git a/release-please-config.json b/release-please-config.json index 53696b0a8..8b6bd2d5f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -80,6 +80,7 @@ "versioning": "default" }, "libs/shared/ofrep-core": { + "release-as": "1.0.0", "release-type": "node", "prerelease": false, "bump-minor-pre-major": true,