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 f23fca45f..3c7db79b4 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,10 +1,10 @@ -import type { FlagMetadata, FlagValue, ResolutionDetails } from '@openfeature/web-sdk'; -import type { ResolutionError } from './resolution-error'; +import type { FlagMetadata } from '@openfeature/web-sdk'; +import type { EvaluationResponse } from '@openfeature/ofrep-core'; /** * Cache of flag values from bulk evaluation. */ -export type FlagCache = { [key: string]: ResolutionDetails | ResolutionError }; +export type FlagCache = { [key: string]: EvaluationResponse }; /** * Cache of metadata from bulk evaluation. 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 69a242f9e..0c9957b60 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 @@ -1,8 +1,8 @@ import { OFREPWebProvider } from './ofrep-web-provider'; import TestLogger from '../../test/test-logger'; +import { ClientProviderEvents, ClientProviderStatus, OpenFeature } from '@openfeature/web-sdk'; // 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'; @@ -159,6 +159,24 @@ describe('OFREPWebProvider', () => { expect(flag.flagMetadata).toEqual(TEST_FLAG_SET_METADATA); }); + it('should return default value if API does not return a value', async () => { + const flagKey = 'flag-without-value'; + const providerName = expect.getState().currentTestName || 'test-provider'; + const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL }, new TestLogger()); + await OpenFeature.setContext(defaultContext); + await OpenFeature.setProviderAndWait(providerName, provider); + const client = OpenFeature.getClient(providerName); + + const flag = client.getNumberDetails(flagKey, 42); + expect(flag).toEqual({ + flagKey, + value: 42, + variant: 'emptyVariant', + flagMetadata: TEST_FLAG_METADATA, + reason: 'DISABLED', + }); + }); + it('should return EvaluationDetails if the flag exists', async () => { const flagKey = 'bool-flag'; const providerName = expect.getState().currentTestName || 'test-provider'; @@ -190,7 +208,7 @@ describe('OFREPWebProvider', () => { flagKey, value: false, errorCode: 'PARSE_ERROR', - errorMessage: 'Flag or flag configuration could not be parsed', + errorMessage: 'custom error details', 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 1a9637fe5..ee80dfa20 100644 --- a/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts +++ b/libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts @@ -1,12 +1,16 @@ import type { EvaluationRequest, EvaluationResponse } from '@openfeature/ofrep-core'; +import { ErrorMessageMap } from '@openfeature/ofrep-core'; import { + type EvaluationFlagValue, + handleEvaluationError, + isEvaluationFailureResponse, OFREPApi, OFREPApiFetchError, OFREPApiTooManyRequestsError, OFREPApiUnauthorizedError, OFREPForbiddenError, - isEvaluationFailureResponse, - isEvaluationSuccessResponse, + toFlagMetadata, + toResolutionDetails, } from '@openfeature/ofrep-core'; import type { EvaluationContext, @@ -30,18 +34,6 @@ import type { EvaluateFlagsResponse } from './model/evaluate-flags-response'; import { BulkEvaluationStatus } from './model/evaluate-flags-response'; import type { FlagCache, MetadataCache } from './model/in-memory-cache'; import type { 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; @@ -62,7 +54,7 @@ export class OFREPWebProvider implements Provider { private _pollingInterval: number; private _retryPollingAfter: Date | undefined; private _flagCache: FlagCache = {}; - private _flagSetMetadataCache: MetadataCache = {}; + private _flagSetMetadataCache?: MetadataCache = {}; private _context: EvaluationContext | undefined; private _pollingIntervalId?: number; @@ -109,28 +101,28 @@ export class OFREPWebProvider implements Provider { defaultValue: boolean, context: EvaluationContext, ): ResolutionDetails { - return this._resolve(flagKey, 'boolean', defaultValue); + return this._resolve(flagKey, defaultValue); } resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext, ): ResolutionDetails { - return this._resolve(flagKey, 'string', defaultValue); + return this._resolve(flagKey, defaultValue); } resolveNumberEvaluation( flagKey: string, defaultValue: number, context: EvaluationContext, ): ResolutionDetails { - return this._resolve(flagKey, 'number', defaultValue); + return this._resolve(flagKey, defaultValue); } resolveObjectEvaluation( flagKey: string, defaultValue: T, context: EvaluationContext, ): ResolutionDetails { - return this._resolve(flagKey, 'object', defaultValue); + return this._resolve(flagKey, defaultValue); } /** @@ -204,36 +196,24 @@ export class OFREPWebProvider implements Provider { } const bulkSuccessResp = response.value; - const newCache: FlagCache = {}; - - 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, - 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 { + if (!('flags' in bulkSuccessResp) || !Array.isArray(bulkSuccessResp.flags)) { throw new Error('No flags in OFREP bulk evaluation response'); } + + const newCache = bulkSuccessResp.flags.reduce((currentCache, currentResponse) => { + if (currentResponse.key) { + currentCache[currentResponse.key] = currentResponse; + } + return currentCache; + }, {}); + + const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache); + this._flagCache = newCache; + this._etag = response.httpResponse?.headers.get('etag'); + this._flagSetMetadataCache = toFlagMetadata( + typeof bulkSuccessResp.metadata === 'object' ? bulkSuccessResp.metadata : {}, + ); + return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags }; } catch (error) { if (error instanceof OFREPApiTooManyRequestsError && error.retryAfterDate !== null) { this._retryPollingAfter = error.retryAfterDate; @@ -278,7 +258,7 @@ export class OFREPWebProvider implements Provider { * @param defaultValue - default value * @private */ - private _resolve(flagKey: string, type: string, defaultValue: T): ResolutionDetails { + private _resolve(flagKey: string, defaultValue: T): ResolutionDetails { const resolved = this._flagCache[flagKey]; if (!resolved) { @@ -291,32 +271,18 @@ export class OFREPWebProvider implements Provider { }; } - if (isResolutionError(resolved)) { - return { - ...resolved, - value: defaultValue, - errorMessage: ErrorMessageMap[resolved.errorCode], - }; - } + return this.responseToResolutionDetails(resolved, defaultValue); + } - if (typeof resolved.value !== type) { - return { - value: defaultValue, - flagMetadata: resolved.flagMetadata, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: ErrorMessageMap[ErrorCode.TYPE_MISMATCH], - }; + private responseToResolutionDetails( + response: EvaluationResponse, + defaultValue: T, + ): ResolutionDetails { + if (isEvaluationFailureResponse(response)) { + return handleEvaluationError(response, defaultValue); } - return { - variant: resolved.variant, - value: resolved.value as T, - flagMetadata: resolved.flagMetadata, - errorCode: resolved.errorCode, - errorMessage: resolved.errorMessage, - reason: resolved.reason, - }; + return toResolutionDetails(response, defaultValue); } /** diff --git a/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts b/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts index 3cf933b16..676ba480e 100644 --- a/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts +++ b/libs/providers/ofrep/src/lib/ofrep-provider.spec.ts @@ -6,7 +6,7 @@ import { OFREPApiUnexpectedResponseError, OFREPForbiddenError, } from '@openfeature/ofrep-core'; -import { ErrorCode, GeneralError, TypeMismatchError } from '@openfeature/server-sdk'; +import { ErrorCode, GeneralError } 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 @@ -166,6 +166,18 @@ describe('OFREPProvider should', () => { expect(flag.value).toEqual(true); }); + it('should return default value if API does not return a value', async () => { + const flag = await provider.resolveNumberEvaluation('flag-without-value', 42, { + errors: { disabled: true }, + }); + expect(flag).toEqual({ + value: 42, + variant: 'emptyVariant', + flagMetadata: TEST_FLAG_METADATA, + reason: 'DISABLED', + }); + }); + it('run successful evaluation of basic boolean flag', async () => { const flag = await provider.resolveBooleanEvaluation('my-flag', false, {}); expect(flag.value).toEqual(true); diff --git a/libs/providers/ofrep/src/lib/ofrep-provider.ts b/libs/providers/ofrep/src/lib/ofrep-provider.ts index 53f0d9f83..4804e487d 100644 --- a/libs/providers/ofrep/src/lib/ofrep-provider.ts +++ b/libs/providers/ofrep/src/lib/ofrep-provider.ts @@ -1,4 +1,5 @@ import type { EvaluationFlagValue, OFREPApiEvaluationResult, OFREPProviderBaseOptions } from '@openfeature/ofrep-core'; +import { isEvaluationFailureResponse } from '@openfeature/ofrep-core'; import { OFREPApi, OFREPApiTooManyRequestsError, @@ -6,7 +7,7 @@ import { toResolutionDetails, } from '@openfeature/ofrep-core'; import type { EvaluationContext, JsonValue, Provider, ResolutionDetails } from '@openfeature/server-sdk'; -import { ErrorCode, GeneralError, StandardResolutionReasons } from '@openfeature/server-sdk'; +import { GeneralError } from '@openfeature/server-sdk'; export type OFREPProviderOptions = OFREPProviderBaseOptions; @@ -91,19 +92,13 @@ export class OFREPProvider implements Provider { defaultValue: T, ): ResolutionDetails { if (result.httpStatus !== 200) { - return handleEvaluationError(result, defaultValue); + return handleEvaluationError(result.value, defaultValue); } - if (typeof result.value.value !== typeof defaultValue) { - return { - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - flagMetadata: result.value.metadata, - errorCode: ErrorCode.TYPE_MISMATCH, - errorMessage: 'Flag is not of expected type', - }; + if (isEvaluationFailureResponse(result)) { + return handleEvaluationError(result, defaultValue); } - return toResolutionDetails(result.value); + return toResolutionDetails(result.value, defaultValue); } } 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 befd0848b..025b42956 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 @@ -288,6 +288,16 @@ describe('OFREPApi', () => { }, }, }, + { + key: 'flag-without-value', + metadata: { + booleanKey: true, + numberKey: 1, + stringKey: 'string', + }, + reason: 'DISABLED', + variant: 'emptyVariant', + }, ], } satisfies BulkEvaluationSuccessResponse); }); @@ -367,6 +377,16 @@ describe('OFREPApi', () => { }, }, }, + { + key: 'flag-without-value', + metadata: { + booleanKey: true, + numberKey: 1, + stringKey: 'string', + }, + reason: 'DISABLED', + variant: 'emptyVariant', + }, ], }); }); 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 d008d88f6..550838dc1 100644 --- a/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts +++ b/libs/shared/ofrep-core/src/lib/api/ofrep-api.ts @@ -1,22 +1,21 @@ import type { FlagMetadata, ResolutionDetails } from '@openfeature/core'; import { ErrorCode, StandardResolutionReasons } from '@openfeature/core'; import type { + EvaluationFailureResponse, EvaluationFlagValue, EvaluationRequest, EvaluationSuccessResponse, - OFREPApiBulkEvaluationFailureResult, OFREPApiBulkEvaluationResult, OFREPApiEvaluationResult, OFREPEvaluationErrorHttpStatus, OFREPEvaluationSuccessHttpStatus, } from '../model'; import { - OFREPEvaluationErrorHttpStatuses, - OFREPEvaluationSuccessHttpStatuses, isBulkEvaluationFailureResponse, isBulkEvaluationSuccessResponse, isEvaluationFailureResponse, - isEvaluationSuccessResponse, + OFREPEvaluationErrorHttpStatuses, + OFREPEvaluationSuccessHttpStatuses, } from '../model'; import type { OFREPProviderBaseOptions } from '../provider'; import { buildHeaders } from '../provider'; @@ -27,6 +26,7 @@ import { OFREPApiUnexpectedResponseError, OFREPForbiddenError, } from './errors'; +import { isDefined } from '../helpers'; export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; @@ -45,6 +45,17 @@ function isomorphicFetch(): FetchAPI { const DEFAULT_TIMEOUT_MS = 10_000; +export 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 OFREPApi { private static readonly jsonRegex = new RegExp(/application\/[^+]*[+]?(json);?.*/, 'i'); @@ -66,7 +77,9 @@ export class OFREPApi { return (OFREPEvaluationSuccessHttpStatuses as readonly number[]).includes(status); } - private async doFetchRequest(req: Request): Promise<{ response: Response; body?: unknown }> { + private async doFetchRequest( + req: Request, + ): Promise<{ response: Response; body?: EvaluationSuccessResponse | EvaluationFailureResponse }> { let response: Response; try { const timeoutMs = this.baseOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -122,7 +135,7 @@ export class OFREPApi { }); const { response, body } = await this.doFetchRequest(request); - if (response.status === 200 && isEvaluationSuccessResponse(body)) { + if (response.status === 200 && body && !isEvaluationFailureResponse(body)) { return { httpStatus: response.status, value: body, httpResponse: response }; } else if (OFREPApi.isOFREFErrorHttpStatus(response.status) && isEvaluationFailureResponse(body)) { return { httpStatus: response.status, value: body, httpResponse: response }; @@ -160,47 +173,65 @@ export class OFREPApi { } export function handleEvaluationError( - resultOrError: OFREPApiBulkEvaluationFailureResult | Error, + resultOrError: EvaluationFailureResponse | Error, defaultValue: T, - callback?: (resultOrError: OFREPApiBulkEvaluationFailureResult | Error) => void, + callback?: (resultOrError: EvaluationFailureResponse | 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; + if ('errorCode' in resultOrError) { + const code = resultOrError.errorCode ?? ErrorCode.GENERAL; + const message = resultOrError.errorDetails ?? ErrorMessageMap[resultOrError.errorCode] ?? resultOrError.errorCode; + const metadata = toFlagMetadata(resultOrError.metadata); - const resolution: ResolutionDetails = { + return { 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 }); - } + throw resultOrError; } } export function toResolutionDetails( result: EvaluationSuccessResponse, + defaultValue: T, ): ResolutionDetails { + if (!isDefined(result.value)) { + return { + value: defaultValue, + variant: result.variant, + flagMetadata: result.metadata, + reason: result.reason || StandardResolutionReasons.DEFAULT, + }; + } + + if (typeof result.value !== typeof defaultValue) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: result.metadata, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: ErrorMessageMap[ErrorCode.TYPE_MISMATCH], + }; + } + return { value: result.value as T, variant: result.variant, reason: result.reason, - flagMetadata: result.metadata && toFlagMetadata(result.metadata), + flagMetadata: toFlagMetadata(result.metadata), }; } -export function toFlagMetadata(metadata: object): FlagMetadata { +export function toFlagMetadata(metadata?: object): FlagMetadata | undefined { + if (!metadata) { + return undefined; + } + // OFREP metadata is defined as any object but OF metadata is defined as Record const originalEntries = Object.entries(metadata); const onlyPrimitiveEntries = originalEntries.filter(([, value]) => diff --git a/libs/shared/ofrep-core/src/lib/helpers.ts b/libs/shared/ofrep-core/src/lib/helpers.ts new file mode 100644 index 000000000..6928d0ce9 --- /dev/null +++ b/libs/shared/ofrep-core/src/lib/helpers.ts @@ -0,0 +1,8 @@ +/** + * Checks whether the parameter is not undefined. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is a string + */ +export function isDefined(value: T | undefined | null): value is T { + return typeof value !== 'undefined'; +} diff --git a/libs/shared/ofrep-core/src/lib/index.ts b/libs/shared/ofrep-core/src/lib/index.ts index 223336198..9ef376dd0 100644 --- a/libs/shared/ofrep-core/src/lib/index.ts +++ b/libs/shared/ofrep-core/src/lib/index.ts @@ -1,3 +1,4 @@ export * from './api'; export * from './model'; export * from './provider'; +export * from './helpers'; diff --git a/libs/shared/ofrep-core/src/lib/model/evaluation.ts b/libs/shared/ofrep-core/src/lib/model/evaluation.ts index e2be7ac53..98f4d2ef2 100644 --- a/libs/shared/ofrep-core/src/lib/model/evaluation.ts +++ b/libs/shared/ofrep-core/src/lib/model/evaluation.ts @@ -7,7 +7,13 @@ export interface EvaluationRequest { context?: EvaluationContext; } -export type EvaluationFlagValue = FlagValue; +/** + * The possible values resulting from a flag evaluation. + * In OFREP, a "disabled" flag is represented by undefined value + * https://github.com/open-feature/protocol/issues/46 + * https://github.com/open-feature/protocol/pull/49/files + */ +export type EvaluationFlagValue = FlagValue | undefined; export interface MetadataResponse { /** @@ -32,15 +38,7 @@ export interface EvaluationSuccessResponse extends MetadataResponse { /** * Flag evaluation result */ - value: EvaluationFlagValue; -} - -export function isEvaluationSuccessResponse(response: unknown): response is EvaluationSuccessResponse { - if (!response || typeof response !== 'object') { - return false; - } - - return 'value' in response; + value?: EvaluationFlagValue; } export interface EvaluationFailureResponse extends MetadataResponse { diff --git a/libs/shared/ofrep-core/src/test/handlers.ts b/libs/shared/ofrep-core/src/test/handlers.ts index af8fab070..d8239e7c1 100644 --- a/libs/shared/ofrep-core/src/test/handlers.ts +++ b/libs/shared/ofrep-core/src/test/handlers.ts @@ -1,6 +1,12 @@ import type { StrictResponse } from 'msw'; import { http, HttpResponse } from 'msw'; -import type { BulkEvaluationResponse, EvaluationFailureResponse, EvaluationRequest, EvaluationResponse } from '../lib'; +import type { + BulkEvaluationResponse, + EvaluationFailureResponse, + EvaluationRequest, + EvaluationResponse, + EvaluationSuccessResponse, +} from '../lib'; import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from './test-constants'; import { ErrorCode, StandardResolutionReasons } from '@openfeature/core'; @@ -120,6 +126,19 @@ export const handlers = [ ); } + if (errors?.['disabled']) { + return HttpResponse.json( + { + key: 'flag-without-value', + value: undefined, + metadata: TEST_FLAG_METADATA, + variant: 'emptyVariant', + reason: StandardResolutionReasons.DISABLED, + }, + { status: 200 }, + ); + } + const scopeValue = new URL(info.request.url).searchParams.get('scope'); return HttpResponse.json({ @@ -352,6 +371,13 @@ export const handlers = [ value: { complex: true, nested: { also: true } }, metadata: TEST_FLAG_METADATA, }, + { + key: 'flag-without-value', + value: undefined, + metadata: TEST_FLAG_METADATA, + variant: 'emptyVariant', + reason: StandardResolutionReasons.DISABLED, + }, ], }, { headers: { etag: '123' } },