diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 4e4c8236..041bbc27 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.6.0-alpha.0", + "version": "1.6.0-alpha.3", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e8637774..28e67598 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -133,7 +133,7 @@ export type FeatureRemoteConfig = * Describes a feature */ export interface Feature< - TConfig extends FeatureRemoteConfig | never = EmptyFeatureRemoteConfig, + TConfig extends FeatureRemoteConfig | undefined = EmptyFeatureRemoteConfig, > { /** * The key of the feature. @@ -148,7 +148,7 @@ export interface Feature< /* * Optional user-defined configuration. */ - config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; + config: TConfig extends undefined ? EmptyFeatureRemoteConfig : TConfig; /** * Track feature usage in Bucket. diff --git a/packages/openfeature-browser-provider/README.md b/packages/openfeature-browser-provider/README.md index 6dfa6e36..f1cced0f 100644 --- a/packages/openfeature-browser-provider/README.md +++ b/packages/openfeature-browser-provider/README.md @@ -15,8 +15,8 @@ The OpenFeature SDK is required as peer dependency. The minimum required version of `@openfeature/web-sdk` currently is `1.0`. -``` -$ npm install @openfeature/web-sdk @bucketco/openfeature-browser-provider +```shell +npm install @openfeature/web-sdk @bucketco/openfeature-browser-provider ``` ## Sample initialization @@ -36,13 +36,69 @@ const client = OpenFeature.getClient(); // use client const boolValue = client.getBooleanValue("huddles", false); -``` -Bucket only supports boolean values. +// use more complex, dynamic config-enabled functionality. +const feedbackConfig = client.getObjectValue("ask-feedback", { + question: "How are you enjoying this feature?", +}); +``` Initializing the Bucket Browser Provider will also initialize [automatic feedback surveys](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk#qualitative-feedback). +## Feature resolution methods + +The Bucket OpenFeature Provider implements the OpenFeature evaluation interface for different value types. Each method handles the resolution of feature flags according to the OpenFeature specification. + +### Common behavior + +All resolution methods share these behaviors: + +- Return default value with `PROVIDER_NOT_READY` if client is not initialized, +- Return default value with `FLAG_NOT_FOUND` if flag doesn't exist, +- Return default value with `ERROR` if there was a type mismatch, +- Return evaluated value with `TARGETING_MATCH` on successful resolution. + +### Type-Specific Methods + +#### Boolean Resolution + +```ts +client.getBooleanValue("my-flag", false); +``` + +Returns the feature's enabled state. This is the most common use case for feature flags. + +#### String Resolution + +```ts +client.getStringValue("my-flag", "default"); +``` + +Returns the feature's remote config key (also known as "variant"). Useful for multi-variate use cases. + +#### Number Resolution + +```ts +client.getNumberValue("my-flag", 0); +``` + +Not directly supported by Bucket. Use `getObjectValue` instead for numeric configurations. + +#### Object Resolution + +```ts +// works for any type: +client.getObjectValue("my-flag", { defaultValue: true }); +client.getObjectValue("my-flag", "string-value"); +client.getObjectValue("my-flag", 199); +``` + +Returns the feature's remote config payload with type validation. This is the most flexible method, +allowing for complex configuration objects or simple types. + +The object resolution performs runtime type checking between the default value and the feature payload to ensure type safety. + ## Context To convert the OpenFeature context to a Bucket appropriate context @@ -61,11 +117,18 @@ const publishableKey = ""; const contextTranslator = (context?: EvaluationContext) => { return { user: { - id: context["trackingKey"], - name: context["name"], - email: context["email"], + id: context.targetingKey ?? context["userId"], + email: context["email"]?.toString(), + name: context["name"]?.toString(), + avatar: context["avatar"]?.toString(), + country: context["country"]?.toString(), + }, + company: { + id: context["companyId"], + name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }, - company: { id: context["orgId"], name: context["orgName"] }, }; }; @@ -81,7 +144,7 @@ To update the context, call `OpenFeature.setContext(myNewContext);` await OpenFeature.setContext({ userId: "my-key" }); ``` -# Tracking feature usage +## Tracking feature usage The Bucket OpenFeature Provider supports the OpenFeature tracking API natively. @@ -103,8 +166,7 @@ const client = OpenFeature.getClient(); client.track("huddles"); ``` -# License - -MIT License +## License -Copyright (c) 2025 Bucket ApS +> MIT License +> Copyright (c) 2025 Bucket ApS diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 1f7a8851..037c3402 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/openfeature-browser-provider", - "version": "0.3.1", + "version": "0.4.0-alpha.0", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { @@ -35,7 +35,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "2.4.0" + "@bucketco/browser-sdk": "3.0.0-alpha.2" }, "devDependencies": { "@bucketco/eslint-config": "0.0.2", diff --git a/packages/openfeature-browser-provider/src/index.test.ts b/packages/openfeature-browser-provider/src/index.test.ts index 54b4e700..4cf03ec7 100644 --- a/packages/openfeature-browser-provider/src/index.test.ts +++ b/packages/openfeature-browser-provider/src/index.test.ts @@ -1,9 +1,9 @@ import { Client, OpenFeature } from "@openfeature/web-sdk"; -import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { BucketClient } from "@bucketco/browser-sdk"; -import { BucketBrowserSDKProvider } from "."; +import { BucketBrowserSDKProvider, defaultContextTranslator } from "."; vi.mock("@bucketco/browser-sdk", () => { const actualModule = vi.importActual("@bucketco/browser-sdk"); @@ -27,23 +27,27 @@ describe("BucketBrowserSDKProvider", () => { getFeature: vi.fn(), initialize: vi.fn().mockResolvedValue({}), track: vi.fn(), + stop: vi.fn(), }; - const newBucketClient = BucketClient as Mock; - newBucketClient.mockReturnValue(bucketClientMock); + const mockBucketClient = BucketClient as Mock; + mockBucketClient.mockReturnValue(bucketClientMock); + + beforeEach(async () => { + await OpenFeature.clearProviders(); - beforeAll(() => { provider = new BucketBrowserSDKProvider({ publishableKey }); OpenFeature.setProvider(provider); ofClient = OpenFeature.getClient(); }); + beforeEach(() => { vi.clearAllMocks(); }); const contextTranslatorFn = vi.fn(); - describe("initialize", () => { + describe("lifecycle", () => { it("should call initialize function with correct arguments", async () => { await provider.initialize(); expect(BucketClient).toHaveBeenCalledTimes(1); @@ -59,6 +63,21 @@ describe("BucketBrowserSDKProvider", () => { expect(bucketClientMock.initialize).toHaveBeenCalledTimes(1); expect(provider.status).toBe("READY"); }); + + it("should call stop function when provider is closed", async () => { + await OpenFeature.clearProviders(); + expect(bucketClientMock.stop).toHaveBeenCalledTimes(1); + }); + + it("onContextChange re-initializes client", async () => { + const p = new BucketBrowserSDKProvider({ publishableKey }); + expect(p["_client"]).toBeUndefined(); + expect(mockBucketClient).toHaveBeenCalledTimes(0); + + await p.onContextChange({}, {}); + expect(mockBucketClient).toHaveBeenCalledTimes(1); + expect(p["_client"]).toBeDefined(); + }); }); describe("contextTranslator", () => { @@ -66,13 +85,26 @@ describe("BucketBrowserSDKProvider", () => { const ofContext = { userId: "123", email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", groupId: "456", groupName: "bucket", + groupAvatar: "https://bucket.co/group-avatar.png", + groupPlan: "pro", }; const bucketContext = { - user: { id: "123", name: "John Doe", email: "john@acme.com" }, - company: { id: "456", name: "Acme, Inc." }, + user: { + id: "123", + name: "John Doe", + email: "john@acme.com", + avatar: "https://acme.com/avatar.png", + }, + company: { + id: "456", + name: "Acme, Inc.", + plan: "pro", + avatar: "https://acme.com/company-avatar.png", + }, }; contextTranslatorFn.mockReturnValue(bucketContext); @@ -80,32 +112,211 @@ describe("BucketBrowserSDKProvider", () => { publishableKey, contextTranslator: contextTranslatorFn, }); + await provider.initialize(ofContext); + expect(contextTranslatorFn).toHaveBeenCalledWith(ofContext); - expect(newBucketClient).toHaveBeenCalledWith({ + expect(mockBucketClient).toHaveBeenCalledWith({ publishableKey, ...bucketContext, }); }); + + it("defaultContextTranslator provides the correct context", async () => { + expect( + defaultContextTranslator({ + userId: 123, + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + companyId: "456", + companyName: "Acme, Inc.", + companyAvatar: "https://acme.com/company-avatar.png", + companyPlan: "pro", + }), + ).toEqual({ + user: { + id: "123", + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + }, + company: { + id: "456", + name: "Acme, Inc.", + plan: "pro", + avatar: "https://acme.com/company-avatar.png", + }, + }); + }); + + it("defaultContextTranslator uses targetingKey if provided", async () => { + expect( + defaultContextTranslator({ + targetingKey: "123", + }), + ).toMatchObject({ + user: { + id: "123", + }, + company: { + id: undefined, + }, + }); + }); }); - describe("resolveBooleanEvaluation", () => { - it("calls the client correctly for boolean evaluation", async () => { + describe("resolving flags", () => { + beforeEach(async () => { + await provider.initialize(); + }); + + function mockFeature( + enabled: boolean, + configKey?: string | null, + configPayload?: any, + ) { + const config = { + key: configKey, + payload: configPayload, + }; + bucketClientMock.getFeature = vi.fn().mockReturnValue({ - isEnabled: true, + isEnabled: enabled, + config, }); + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ [testFlagKey]: { - isEnabled: true, - targetingVersion: 1, + isEnabled: enabled, + config: { + key: "key", + payload: configPayload, + }, }, }); - await provider.initialize(); + } + + it("returns error if provider is not initialized", async () => { + await OpenFeature.clearProviders(); + + const val = ofClient.getBooleanDetails(testFlagKey, true); + + expect(val).toMatchObject({ + flagKey: testFlagKey, + flagMetadata: {}, + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY", + value: true, + }); + }); + + it("returns error if flag is not found", async () => { + mockFeature(true, "key", true); + const val = ofClient.getBooleanDetails("missing-key", true); + + expect(val).toMatchObject({ + flagKey: "missing-key", + flagMetadata: {}, + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + value: true, + }); + }); + + it("calls the client correctly when evaluating", async () => { + mockFeature(true, "key", true); + + const val = ofClient.getBooleanDetails(testFlagKey, false); + + expect(val).toMatchObject({ + flagKey: testFlagKey, + flagMetadata: {}, + reason: "TARGETING_MATCH", + variant: "key", + value: true, + }); - ofClient.getBooleanDetails(testFlagKey, false); expect(bucketClientMock.getFeatures).toHaveBeenCalled(); expect(bucketClientMock.getFeature).toHaveBeenCalledWith(testFlagKey); }); + + it.each([ + [true, false, true, "TARGETING_MATCH", undefined], + [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"], + [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"], + ])( + "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + (enabled, def, expected, reason, errorCode) => { + const configKey = enabled !== undefined ? "variant-1" : undefined; + const flagKey = enabled ? testFlagKey : "missing-key"; + + mockFeature(enabled ?? false, configKey); + + expect(ofClient.getBooleanDetails(flagKey, def)).toMatchObject({ + flagKey, + flagMetadata: {}, + reason, + value: expected, + ...(errorCode ? { errorCode } : {}), + ...(configKey ? { variant: configKey } : {}), + }); + }, + ); + + it("should return error when evaluating number", async () => { + expect(ofClient.getNumberDetails(testFlagKey, 1)).toMatchObject({ + flagKey: testFlagKey, + flagMetadata: {}, + reason: "ERROR", + errorCode: "GENERAL", + value: 1, + }); + }); + + it.each([ + ["key-1", "default", "key-1", "TARGETING_MATCH"], + [null, "default", "default", "DEFAULT"], + [undefined, "default", "default", "DEFAULT"], + ])( + "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`", + (variant, def, expected, reason) => { + mockFeature(true, variant, {}); + expect(ofClient.getStringDetails(testFlagKey, def)).toMatchObject({ + flagKey: testFlagKey, + flagMetadata: {}, + reason, + value: expected, + ...(variant ? { variant } : {}), + }); + }, + ); + + it.each([ + ["one", {}, { a: 1 }, {}, "TARGETING_MATCH", undefined], + ["two", "string", "default", "string", "TARGETING_MATCH", undefined], + ["three", 15, 16, 15, "TARGETING_MATCH", undefined], + ["four", true, true, true, "TARGETING_MATCH", undefined], + ["five", 100, "string", "string", "ERROR", "TYPE_MISMATCH"], + ["six", 1337, true, true, "ERROR", "TYPE_MISMATCH"], + ["seven", "string", 1337, 1337, "ERROR", "TYPE_MISMATCH"], + [undefined, null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"], + [undefined, undefined, "a", "a", "ERROR", "TYPE_MISMATCH"], + ])( + "should return the correct result when evaluating object. variant: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + (variant, value, def, expected, reason, errorCode) => { + mockFeature(true, variant, value); + + expect(ofClient.getObjectDetails(testFlagKey, def)).toMatchObject({ + flagKey: testFlagKey, + flagMetadata: {}, + reason, + value: expected, + ...(errorCode ? { errorCode } : {}), + ...(variant && !errorCode ? { variant } : {}), + }); + }, + ); }); describe("track", () => { @@ -120,16 +331,4 @@ describe("BucketBrowserSDKProvider", () => { }); }); }); - - describe("onContextChange", () => { - it("re-initialize client", async () => { - const p = new BucketBrowserSDKProvider({ publishableKey }); - expect(p["_client"]).toBeUndefined(); - expect(newBucketClient).toHaveBeenCalledTimes(0); - - await p.onContextChange({}, {}); - expect(newBucketClient).toHaveBeenCalledTimes(1); - expect(p["_client"]).toBeDefined(); - }); - }); }); diff --git a/packages/openfeature-browser-provider/src/index.ts b/packages/openfeature-browser-provider/src/index.ts index 48894ecb..9184c4fd 100644 --- a/packages/openfeature-browser-provider/src/index.ts +++ b/packages/openfeature-browser-provider/src/index.ts @@ -11,28 +11,29 @@ import { TrackingEventDetails, } from "@openfeature/web-sdk"; -import { BucketClient, InitOptions } from "@bucketco/browser-sdk"; +import { BucketClient, Feature, InitOptions } from "@bucketco/browser-sdk"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type ContextTranslationFn = ( context?: EvaluationContext, ) => Record; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export function defaultContextTranslator( context?: EvaluationContext, ): Record { if (!context) return {}; return { user: { - id: context["trackingKey"], - email: context["email"], - name: context["name"], + id: context.targetingKey ?? context["userId"]?.toString(), + email: context["email"]?.toString(), + name: context["name"]?.toString(), + avatar: context["avatar"]?.toString(), + country: context["country"]?.toString(), }, company: { - id: context["companyId"], - name: context["companyName"], - plan: context["companyPlan"], + id: context["companyId"]?.toString(), + name: context["companyName"]?.toString(), + plan: context["companyPlan"]?.toString(), + avatar: context["companyAvatar"]?.toString(), }, }; } @@ -44,8 +45,8 @@ export class BucketBrowserSDKProvider implements Provider { private _client?: BucketClient; - private _clientOptions: InitOptions; - private _contextTranslator: ContextTranslationFn; + private readonly _clientOptions: InitOptions; + private readonly _contextTranslator: ContextTranslationFn; public events = new OpenFeatureEventEmitter(); @@ -100,66 +101,102 @@ export class BucketBrowserSDKProvider implements Provider { await this.initialize(newContext); } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - ): ResolutionDetails { - if (!this._client) + defaultValue: T, + resolveFn: (feature: Feature) => ResolutionDetails, + ): ResolutionDetails { + if (!this._client) { return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, errorCode: ErrorCode.PROVIDER_NOT_READY, - } satisfies ResolutionDetails; + errorMessage: "Bucket client not initialized", + } satisfies ResolutionDetails; + } const features = this._client.getFeatures(); if (flagKey in features) { - const feature = this._client.getFeature(flagKey); - return { - value: feature.isEnabled, - reason: StandardResolutionReasons.TARGETING_MATCH, - } satisfies ResolutionDetails; + return resolveFn(this._client.getFeature(flagKey)); } return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT, - } satisfies ResolutionDetails; + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, + }; } - resolveNumberEvaluation( - _flagKey: string, - defaultValue: number, - ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support number flags", - }; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean) { + return this.resolveFeature(flagKey, defaultValue, (feature) => { + return { + value: feature.isEnabled, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + }); } - resolveObjectEvaluation( - _flagKey: string, - defaultValue: T, - ): ResolutionDetails { + resolveNumberEvaluation(_flagKey: string, defaultValue: number) { return { value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support object flags", + errorCode: ErrorCode.GENERAL, + errorMessage: + "Bucket doesn't support this method. Use `resolveObjectEvaluation` instead.", }; } resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, ): ResolutionDetails { - return { - value: defaultValue, - errorCode: ErrorCode.TYPE_MISMATCH, - reason: StandardResolutionReasons.ERROR, - errorMessage: "Bucket doesn't support string flags", - }; + return this.resolveFeature(flagKey, defaultValue, (feature) => { + if (!feature.config.key) { + return { + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }; + } + + return { + value: feature.config.key as string, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + }); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + ) { + return this.resolveFeature(flagKey, defaultValue, (feature) => { + const expType = typeof defaultValue; + + const payloadType = typeof feature.config.payload; + + if ( + feature.config.payload === undefined || + feature.config.payload === null || + payloadType !== expType + ) { + return { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + variant: feature.config.key, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`, + }; + } + + return { + value: feature.config.payload, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }; + }); } track( @@ -171,8 +208,10 @@ export class BucketBrowserSDKProvider implements Provider { this._clientOptions.logger?.error("client not initialized"); } - this._client?.track(trackingEventName, trackingEventDetails).catch((e) => { - this._clientOptions.logger?.error("error tracking event", e); - }); + this._client + ?.track(trackingEventName, trackingEventDetails) + .catch((e: any) => { + this._clientOptions.logger?.error("error tracking event", e); + }); } } diff --git a/packages/openfeature-node-provider/README.md b/packages/openfeature-node-provider/README.md index f9183534..75699165 100644 --- a/packages/openfeature-node-provider/README.md +++ b/packages/openfeature-node-provider/README.md @@ -4,26 +4,23 @@ The official OpenFeature Node.js provider for [Bucket](https://bucket.co) featur ## Installation -``` -$ npm install @bucketco/openfeature-node-provider +```shell +npm install @bucketco/openfeature-node-provider ``` -#### Required peer dependencies +### Required peer dependencies The OpenFeature SDK is required as peer dependency. - The minimum required version of `@openfeature/server-sdk` currently is `1.13.5`. - The minimum required version of `@bucketco/node-sdk` currently is `2.0.0`. -``` -$ npm install @openfeature/server-sdk @bucketco/node-sdk +```shell +npm install @openfeature/server-sdk @bucketco/node-sdk ``` ## Usage The provider uses the [Bucket Node.js SDK](https://docs.bucket.co/quickstart/supported-languages-frameworks/node.js-sdk). - The available options can be found in the [Bucket Node.js SDK](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/node-sdk#initialization-options). ### Example using the default configuration @@ -56,6 +53,59 @@ const enterpriseFeatureEnabled = await client.getBooleanValue( ); ``` +## Feature resolution methods + +The Bucket OpenFeature Provider implements the OpenFeature evaluation interface for different value types. Each method handles the resolution of feature flags according to the OpenFeature specification. + +### Common behavior + +All resolution methods share these behaviors: + +- Return default value with `PROVIDER_NOT_READY` if client is not initialized, +- Return default value with `FLAG_NOT_FOUND` if flag doesn't exist, +- Return default value with `ERROR` if there was a type mismatch, +- Return evaluated value with `TARGETING_MATCH` on successful resolution. + +### Type-Specific Methods + +#### Boolean Resolution + +```ts +client.getBooleanValue("my-flag", false); +``` + +Returns the feature's enabled state. This is the most common use case for feature flags. + +#### String Resolution + +```ts +client.getStringValue("my-flag", "default"); +``` + +Returns the feature's remote config key (also known as "variant"). Useful for multi-variate use cases. + +#### Number Resolution + +```ts +client.getNumberValue("my-flag", 0); +``` + +Not directly supported by Bucket. Use `getObjectValue` instead for numeric configurations. + +#### Object Resolution + +```ts +// works for any type: +client.getObjectValue("my-flag", { defaultValue: true }); +client.getObjectValue("my-flag", "string-value"); +client.getObjectValue("my-flag", 199); +``` + +Returns the feature's remote config payload with type validation. This is the most flexible method, +allowing for complex configuration objects or simple types. + +The object resolution performs runtime type checking between the default value and the feature payload to ensure type safety. + ## Translating Evaluation Context Bucket uses a context object of the following shape: @@ -69,11 +119,19 @@ export type BucketContext = { /** * The user context. If the user is set, the user ID is required. */ - user?: { id: string; [k: string]: any }; + user?: { + id: string; + name?: string; + email?: string; + avatar?: string; + [k: string]: any; + }; + /** * The company context. If the company is set, the company ID is required. */ - company?: { id: string; [k: string]: any }; + company?: { id: string; name?: string; avatar?: string; [k: string]: any }; + /** * The other context. This is used for any additional context that is not related to user or company. */ @@ -91,14 +149,17 @@ import { BucketNodeProvider } from "@openfeature/bucket-node-provider"; const contextTranslator = (context: EvaluationContext): BucketContext => { return { user: { - id: context.targetingKey, + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }, company: { - id: context["companyId"], - name: context["companyName"], + id: context["companyId"]?.toString(), + name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }, }; }; @@ -115,7 +176,7 @@ It's straight forward to start sending tracking events through OpenFeature. Simply call the "track" method on the OpenFeature client: -```ts +```typescript import { BucketNodeProvider } from "@bucketco/openfeature-node-provider"; import { OpenFeature } from "@openfeature/server-sdk"; @@ -132,8 +193,7 @@ const enterpriseFeatureEnabled = await client.track( ); ``` -# License - -MIT License +## License -Copyright (c) 2025 Bucket ApS +> MIT License +> Copyright (c) 2025 Bucket ApS diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 06eca2f5..5cf1a21a 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/openfeature-node-provider", - "version": "0.2.1", + "version": "0.3.0-alpha.0", "license": "MIT", "repository": { "type": "git", @@ -44,7 +44,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/node-sdk": ">=1.4.2" + "@bucketco/node-sdk": "1.6.0-alpha.3" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/packages/openfeature-node-provider/src/index.test.ts b/packages/openfeature-node-provider/src/index.test.ts index 8f506df1..9d2b63b9 100644 --- a/packages/openfeature-node-provider/src/index.test.ts +++ b/packages/openfeature-node-provider/src/index.test.ts @@ -1,9 +1,9 @@ -import { ErrorCode } from "@openfeature/core"; -import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { ProviderStatus } from "@openfeature/server-sdk"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { BucketClient } from "@bucketco/node-sdk"; -import { BucketNodeProvider } from "./index"; +import { BucketNodeProvider, defaultContextTranslator } from "./index"; vi.mock("@bucketco/node-sdk", () => { const actualModule = vi.importActual("@bucketco/node-sdk"); @@ -17,6 +17,7 @@ vi.mock("@bucketco/node-sdk", () => { const bucketClientMock = { getFeatures: vi.fn(), + getFeature: vi.fn(), initialize: vi.fn().mockResolvedValue({}), flush: vi.fn(), track: vi.fn(), @@ -35,6 +36,8 @@ const bucketContext = { company: { id: "99" }, }; +const testFlagKey = "a-key"; + beforeEach(() => { vi.clearAllMocks(); }); @@ -42,121 +45,305 @@ beforeEach(() => { describe("BucketNodeProvider", () => { let provider: BucketNodeProvider; - const newBucketClient = BucketClient as Mock; - newBucketClient.mockReturnValue(bucketClientMock); + const mockBucketClient = BucketClient as Mock; + mockBucketClient.mockReturnValue(bucketClientMock); + + let mockTranslatorFn: Mock; + + function mockFeature( + enabled: boolean, + configKey?: string | null, + configPayload?: any, + ) { + const config = { + key: configKey, + payload: configPayload, + }; + + bucketClientMock.getFeature = vi.fn().mockReturnValue({ + isEnabled: enabled, + config, + }); + + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ + [testFlagKey]: { + isEnabled: enabled, + config: { + key: "key", + payload: configPayload, + }, + }, + }); + } - const translatorFn = vi.fn().mockReturnValue(bucketContext); + beforeEach(async () => { + mockTranslatorFn = vi.fn().mockReturnValue(bucketContext); - beforeAll(async () => { provider = new BucketNodeProvider({ secretKey, - contextTranslator: translatorFn, + contextTranslator: mockTranslatorFn, }); + await provider.initialize(); }); - it("calls the constructor", () => { - provider = new BucketNodeProvider({ - secretKey, - contextTranslator: translatorFn, + describe("contextTranslator", () => { + it("defaultContextTranslator provides the correct context", async () => { + expect( + defaultContextTranslator({ + userId: 123, + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + companyId: "456", + companyName: "Acme, Inc.", + companyAvatar: "https://acme.com/company-avatar.png", + companyPlan: "pro", + }), + ).toEqual({ + user: { + id: "123", + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + }, + company: { + id: "456", + name: "Acme, Inc.", + plan: "pro", + avatar: "https://acme.com/company-avatar.png", + }, + }); + }); + + it("defaultContextTranslator uses targetingKey if provided", async () => { + expect( + defaultContextTranslator({ + targetingKey: "123", + }), + ).toMatchObject({ + user: { + id: "123", + }, + company: { + id: undefined, + }, + }); }); - expect(newBucketClient).toHaveBeenCalledTimes(1); - expect(newBucketClient).toHaveBeenCalledWith({ secretKey }); }); - it("uses the contextTranslator function", async () => { - const track = vi.fn(); - bucketClientMock.getFeatures.mockReturnValue({ - booleanTrue: { - isEnabled: true, - key: "booleanTrue", - track, - }, + describe("lifecycle", () => { + it("calls the constructor of BucketClient", () => { + mockBucketClient.mockClear(); + + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + expect(mockBucketClient).toHaveBeenCalledTimes(1); + expect(mockBucketClient).toHaveBeenCalledWith({ secretKey }); }); - await provider.resolveBooleanEvaluation("booleanTrue", false, context); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); - expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); - expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + it("should set the status to READY if initialization succeeds", async () => { + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("should keep the status as READY after closing", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + await provider.onClose(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("calls flush when provider is closed", async () => { + await provider.onClose(); + expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + }); + + it("uses the contextTranslator function", async () => { + mockFeature(true); + + await provider.resolveBooleanEvaluation(testFlagKey, false, context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); + + expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); + expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + }); }); - describe("method resolveBooleanEvaluation", () => { - it("should return right value if key exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "booleanTrue", - false, - context, - ); - expect(result.value).toEqual(true); - expect(result.errorCode).toBeUndefined(); + describe("resolving flags", () => { + beforeEach(async () => { + await provider.initialize(); }); - it("should return the default value if key does not exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "non-existent", + it("returns error if provider is not initialized", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, true, context, ); - expect(result.value).toEqual(true); - expect(result.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY", + value: true, + }); }); - }); - describe("method resolveNumberEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveNumberEvaluation("number1", 42); - expect(result.value).toEqual(42); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support number flags`, + it("returns error if flag is not found", async () => { + mockFeature(true, "key", true); + const val = await provider.resolveBooleanEvaluation( + "missing-key", + true, + context, ); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + value: true, + }); }); - }); - describe("method resolveStringEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveStringEvaluation( - "number1", - "defaultValue", + it("calls the client correctly when evaluating", async () => { + mockFeature(true, "key", true); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, + false, + context, ); - expect(result.value).toEqual("defaultValue"); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support string flags`, + + expect(val).toMatchObject({ + reason: "TARGETING_MATCH", + value: true, + }); + + expect(bucketClientMock.getFeatures).toHaveBeenCalled(); + expect(bucketClientMock.getFeature).toHaveBeenCalledWith( + bucketContext, + testFlagKey, ); }); - }); - describe("method resolveObjectEvaluation", () => { - it("should return the default value and an error message", async () => { - const defaultValue = { key: "value" }; - const result = await provider.resolveObjectEvaluation( - "number1", - defaultValue, - ); - expect(result.value).toEqual(defaultValue); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support object flags`, - ); + + it.each([ + [true, false, true, "TARGETING_MATCH", undefined], + [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"], + [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"], + ])( + "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (enabled, def, expected, reason, errorCode) => { + const configKey = enabled !== undefined ? "variant-1" : undefined; + + mockFeature(enabled ?? false, configKey); + const flagKey = enabled ? testFlagKey : "missing-key"; + + expect( + await provider.resolveBooleanEvaluation(flagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(configKey ? { variant: configKey } : {}), + ...(errorCode ? { errorCode } : {}), + }); + }, + ); + + it("should return error when context is missing user ID", async () => { + mockTranslatorFn.mockReturnValue({ user: {} }); + + expect( + await provider.resolveBooleanEvaluation(testFlagKey, true, context), + ).toMatchObject({ + reason: "ERROR", + errorCode: "INVALID_CONTEXT", + value: true, + }); }); - }); - describe("onClose", () => { - it("calls flush", async () => { - await provider.onClose(); - expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + it("should return error when evaluating number", async () => { + expect( + await provider.resolveNumberEvaluation(testFlagKey, 1), + ).toMatchObject({ + reason: "ERROR", + errorCode: "GENERAL", + value: 1, + }); }); + + it.each([ + ["key-1", "default", "key-1", "TARGETING_MATCH"], + [null, "default", "default", "DEFAULT"], + [undefined, "default", "default", "DEFAULT"], + ])( + "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`", + async (variant, def, expected, reason) => { + mockFeature(true, variant, {}); + expect( + await provider.resolveStringEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(variant ? { variant } : {}), + }); + }, + ); + + it.each([ + [{}, { a: 1 }, {}, "TARGETING_MATCH", undefined], + ["string", "default", "string", "TARGETING_MATCH", undefined], + [15, -15, 15, "TARGETING_MATCH", undefined], + [true, false, true, "TARGETING_MATCH", undefined], + [null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"], + [100, "string", "string", "ERROR", "TYPE_MISMATCH"], + [true, 1337, 1337, "ERROR", "TYPE_MISMATCH"], + ["string", 1337, 1337, "ERROR", "TYPE_MISMATCH"], + [undefined, "default", "default", "ERROR", "TYPE_MISMATCH"], + ])( + "should return the correct result when evaluating object. payload: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (value, def, expected, reason, errorCode) => { + const configKey = value === undefined ? undefined : "config-key"; + mockFeature(true, configKey, value); + expect( + await provider.resolveObjectEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(errorCode ? { errorCode, variant: configKey } : {}), + }); + }, + ); }); describe("track", () => { it("should track", async () => { - expect(translatorFn).toHaveBeenCalledTimes(0); + expect(mockTranslatorFn).toHaveBeenCalledTimes(0); provider.track("event", context, { action: "click", }); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); expect(bucketClientMock.track).toHaveBeenCalledTimes(1); expect(bucketClientMock.track).toHaveBeenCalledWith("42", "event", { attributes: { action: "click" }, diff --git a/packages/openfeature-node-provider/src/index.ts b/packages/openfeature-node-provider/src/index.ts index 7f2f512a..ad771095 100644 --- a/packages/openfeature-node-provider/src/index.ts +++ b/packages/openfeature-node-provider/src/index.ts @@ -21,17 +21,22 @@ type ProviderOptions = ClientOptions & { contextTranslator?: (context: EvaluationContext) => BucketContext; }; -const defaultTranslator = (context: EvaluationContext): BucketContext => { +export const defaultContextTranslator = ( + context: EvaluationContext, +): BucketContext => { const user = { - id: context.targetingKey ?? context["id"]?.toString(), + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }; const company = { id: context["companyId"]?.toString(), name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }; return { @@ -61,7 +66,7 @@ export class BucketNodeProvider implements Provider { constructor({ contextTranslator, ...opts }: ProviderOptions) { this._client = new BucketClient(opts); - this.contextTranslator = contextTranslator ?? defaultTranslator; + this.contextTranslator = contextTranslator ?? defaultContextTranslator; } public async initialize(): Promise { @@ -69,42 +74,90 @@ export class BucketNodeProvider implements Provider { this.status = ServerProviderStatus.READY; } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - context: EvaluationContext, - ): Promise> { - const features = this._client.getFeatures(this.contextTranslator(context)); + defaultValue: T, + context: BucketContext, + resolveFn: ( + feature: ReturnType, + ) => Promise>, + ): Promise> { + if (this.status !== ServerProviderStatus.READY) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.PROVIDER_NOT_READY, + errorMessage: "Bucket client not initialized", + }); + } - const feature = features[flagKey]; - if (!feature) { + if (!context.user?.id) { return Promise.resolve({ value: defaultValue, - source: "bucket-node", - flagKey, - errorCode: ErrorCode.FLAG_NOT_FOUND, reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.INVALID_CONTEXT, + errorMessage: "At least a user ID is required", }); } + const features = this._client.getFeatures(context); + if (flagKey in features) { + return resolveFn(this._client.getFeature(context, flagKey)); + } + return Promise.resolve({ - value: feature.isEnabled, - source: "bucket-node", - flagKey, - reason: StandardResolutionReasons.TARGETING_MATCH, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, }); } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + return Promise.resolve({ + value: feature.isEnabled, + variant: feature.config?.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); + } + resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support string flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + if (!feature.config.key) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }); + } + + return Promise.resolve({ + value: feature.config.key as string, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } + resolveNumberEvaluation( _flagKey: string, defaultValue: number, @@ -113,19 +166,45 @@ export class BucketNodeProvider implements Provider { value: defaultValue, reason: StandardResolutionReasons.ERROR, errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support number flags", + errorMessage: + "Bucket doesn't support this method. Use `resolveObjectEvaluation` instead.", }); } + resolveObjectEvaluation( - _flagKey: string, + flagKey: string, defaultValue: T, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support object flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + const expType = typeof defaultValue; + const payloadType = typeof feature.config.payload; + + if ( + feature.config.payload === undefined || + feature.config.payload === null || + payloadType !== expType + ) { + return Promise.resolve({ + value: defaultValue, + variant: feature.config.key, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`, + }); + } + + return Promise.resolve({ + value: feature.config.payload, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } track( diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 9d85387a..be92ff43 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -194,6 +194,11 @@ type RequestFeedbackOptions = Omit< "featureKey" | "featureId" >; +type EmptyFeatureConfig = { + key: undefined; + payload: undefined; +}; + type Feature = { isEnabled: boolean; isLoading: boolean; @@ -202,10 +207,7 @@ type Feature = { key: string; payload: FeatureConfig; } - | { - key: undefined; - payload: undefined; - }; + | EmptyFeatureConfig; track: () => void; requestFeedback: (opts: RequestFeedbackOptions) => void; }; diff --git a/yarn.lock b/yarn.lock index 3b0ac3f4..2200fab5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,18 +882,6 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.4.0": - version: 2.4.0 - resolution: "@bucketco/browser-sdk@npm:2.4.0" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - canonical-json: "npm:^0.0.4" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/b33a9fdafa4a857ac4f815fe69b602b37527a37d54270abd479b754da998d030f5a70b738c662ab57fa4f6374b8e1fbd052feb8bdbd8b78367086dcedc5a5432 - languageName: node - linkType: hard - "@bucketco/browser-sdk@npm:3.0.0-alpha.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" @@ -958,16 +946,7 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:>=1.4.2": - version: 1.5.0 - resolution: "@bucketco/node-sdk@npm:1.5.0" - dependencies: - "@bucketco/flag-evaluation": "npm:~0.1.0" - checksum: 10c0/63230400c0c0fa6ccf8708550bbcf583cc58bd18a2b99e19ec1dde43bce593c43136790ff3f0573f171c123c6a0555eebafcefdfa5cc71a2e706079fdb1ebe39 - languageName: node - linkType: hard - -"@bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:1.6.0-alpha.3, @bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: @@ -993,7 +972,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/openfeature-browser-provider@workspace:packages/openfeature-browser-provider" dependencies: - "@bucketco/browser-sdk": "npm:2.4.0" + "@bucketco/browser-sdk": "npm:3.0.0-alpha.2" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@openfeature/core": "npm:1.5.0" @@ -1017,7 +996,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/node-sdk": "npm:>=1.4.2" + "@bucketco/node-sdk": "npm:1.6.0-alpha.3" "@bucketco/tsconfig": "npm:~0.0.2" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1"