Skip to content

Commit

Permalink
feat: Create a Growthbook server side provider (#938)
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Samper <[email protected]>
  • Loading branch information
msamper authored Jan 8, 2025
1 parent b4da066 commit 0e6a486
Show file tree
Hide file tree
Showing 19 changed files with 636 additions and 6 deletions.
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
"libs/providers/multi-provider-web": "0.0.3",
"libs/providers/growthbook-client": "0.1.2",
"libs/providers/config-cat-web": "0.1.3",
"libs/shared/config-cat-core": "0.1.0"
"libs/shared/config-cat-core": "0.1.0",
"libs/providers/growthbook": "0.1.1"
}
25 changes: 25 additions & 0 deletions libs/providers/growthbook/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": "error"
}
}
]
}
45 changes: 45 additions & 0 deletions libs/providers/growthbook/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# growthbook Provider

## Installation

```
$ npm install @openfeature/growthbook-provider
```

## Example Setup

```typescript
import { GrowthBookClient, ClientOptions, InitOptions } from '@growthbook/growthbook';
import { GrowthbookProvider } from '@openfeature/growthbook-provider';
import { OpenFeature } from '@openfeature/server-sdk';

/*
* Configure your GrowthBook instance with GrowthBook context
* @see https://docs.growthbook.io/lib/js#step-1-configure-your-app
*/
const gbClientOptions: ClientOptions = {
apiHost: 'https://cdn.growthbook.io',
clientKey: 'sdk-abc123',
// Only required if you have feature encryption enabled in GrowthBook
decryptionKey: 'key_abc123',
};

/*
* optional init options
* @see https://docs.growthbook.io/lib/js#switching-to-init
*/
const initOptions: InitOptions = {
timeout: 2000,
streaming: true,
};

OpenFeature.setProvider(new GrowthbookProvider(gbClientOptions, initOptions));
```

## Building

Run `nx package providers-growthbook` to build the library.

## Running unit tests

Run `nx test providers-growthbook` to execute the unit tests via [Jest](https://jestjs.io).
3 changes: 3 additions & 0 deletions libs/providers/growthbook/babel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": [["minify", { "builtIns": false }]]
}
10 changes: 10 additions & 0 deletions libs/providers/growthbook/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable */
export default {
displayName: 'providers-growthbook',
preset: '../../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/providers/growthbook',
};
17 changes: 17 additions & 0 deletions libs/providers/growthbook/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@openfeature/growthbook-provider",
"version": "0.1.1",
"dependencies": {
"tslib": "^2.3.0"
},
"main": "./src/index.js",
"typings": "./src/index.d.ts",
"scripts": {
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@growthbook/growthbook": "^1.3.1",
"@openfeature/server-sdk": "^1.13.0"
}
}
76 changes: 76 additions & 0 deletions libs/providers/growthbook/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "providers-growthbook",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/providers/growthbook/src",
"projectType": "library",
"targets": {
"publish": {
"executor": "nx:run-commands",
"options": {
"command": "npm run publish-if-not-exists",
"cwd": "dist/libs/providers/growthbook"
},
"dependsOn": [
{
"projects": "self",
"target": "package"
}
]
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/providers/growthbook/**/*.ts", "libs/providers/growthbook/package.json"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/providers/growthbook/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"package": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"project": "libs/providers/growthbook/package.json",
"outputPath": "dist/libs/providers/growthbook",
"entryFile": "libs/providers/growthbook/src/index.ts",
"tsConfig": "libs/providers/growthbook/tsconfig.lib.json",
"buildableProjectDepsInPackageJsonType": "dependencies",
"compiler": "tsc",
"generateExportsField": true,
"umdName": "growthbook",
"external": "all",
"format": ["cjs", "esm"],
"assets": [
{
"glob": "package.json",
"input": "./assets",
"output": "./src/"
},
{
"glob": "LICENSE",
"input": "./",
"output": "./"
},
{
"glob": "README.md",
"input": "./libs/providers/growthbook",
"output": "./"
}
]
}
}
},
"tags": []
}
1 change: 1 addition & 0 deletions libs/providers/growthbook/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/growthbook-provider';
182 changes: 182 additions & 0 deletions libs/providers/growthbook/src/lib/growthbook-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { ClientOptions, GrowthBookClient, InitOptions } from '@growthbook/growthbook';
import { GrowthbookProvider } from './growthbook-provider';
import { Client, OpenFeature } from '@openfeature/server-sdk';

jest.mock('@growthbook/growthbook');

const testFlagKey = 'flag-key';
const growthbookOptionsMock: ClientOptions = {
apiHost: 'http://api.growthbook.io',
clientKey: 'sdk-test-key',
globalAttributes: {
id: 1,
},
};

const initOptionsMock: InitOptions = {
timeout: 5000,
};

describe('GrowthbookProvider', () => {
let gbProvider: GrowthbookProvider;
let ofClient: Client;

beforeAll(() => {
gbProvider = new GrowthbookProvider(growthbookOptionsMock, initOptionsMock);
OpenFeature.setProvider(gbProvider);
ofClient = OpenFeature.getClient();
});
beforeEach(() => {
jest.clearAllMocks();
});

it('should be and instance of GrowthbookProvider', () => {
expect(new GrowthbookProvider(growthbookOptionsMock, initOptionsMock)).toBeInstanceOf(GrowthbookProvider);
});

describe('constructor', () => {
it('should set the growthbook options & initOptions correctly', () => {
const provider = new GrowthbookProvider(growthbookOptionsMock, initOptionsMock);

expect(provider['options']).toEqual(growthbookOptionsMock);
expect(provider['_initOptions']).toEqual(initOptionsMock);
});
});

describe('initialize', () => {
const provider = new GrowthbookProvider(growthbookOptionsMock, initOptionsMock);

it('should call growthbook initialize function with correct arguments', async () => {
const evalContext = { serverIp: '10.1.1.1' };
await provider.initialize({ serverIp: '10.1.1.1' });

const options = {
...provider['options'],
globalAttributes: { ...provider['options'].globalAttributes, ...evalContext },
};

expect(GrowthBookClient).toHaveBeenCalledWith(options);
expect(provider['_client']?.init).toHaveBeenCalledWith(initOptionsMock);
});
});

describe('resolveBooleanEvaluation', () => {
it('handles correct return types for boolean variations', async () => {
jest.spyOn(GrowthBookClient.prototype, 'evalFeature').mockImplementation(() => ({
value: true,
source: 'experiment',
on: true,
off: false,
ruleId: 'test',
experimentResult: {
value: true,
variationId: 1,
key: 'treatment',
inExperiment: true,
hashAttribute: 'id',
hashValue: 'abc',
featureId: testFlagKey,
},
}));

const res = await ofClient.getBooleanDetails(testFlagKey, false);
expect(res).toEqual({
flagKey: testFlagKey,
flagMetadata: {},
value: true,
reason: 'experiment',
variant: 'treatment',
});
});
});

describe('resolveStringEvaluation', () => {
it('handles correct return types for string variations', async () => {
jest.spyOn(GrowthBookClient.prototype, 'evalFeature').mockImplementation(() => ({
value: 'Experiment fearlessly, deliver confidently',
source: 'experiment',
on: true,
off: false,
ruleId: 'test',
experimentResult: {
value: 'Experiment fearlessly, deliver confidently',
variationId: 1,
key: 'treatment',
inExperiment: true,
hashAttribute: 'id',
hashValue: 'abc',
featureId: testFlagKey,
},
}));

const res = await ofClient.getStringDetails(testFlagKey, '');
expect(res).toEqual({
flagKey: testFlagKey,
flagMetadata: {},
value: 'Experiment fearlessly, deliver confidently',
reason: 'experiment',
variant: 'treatment',
});
});
});

describe('resolveNumberEvaluation', () => {
it('handles correct return types for number variations', async () => {
jest.spyOn(GrowthBookClient.prototype, 'evalFeature').mockImplementation(() => ({
value: 12345,
source: 'experiment',
on: true,
off: false,
ruleId: 'test',
experimentResult: {
value: 12345,
variationId: 1,
key: 'treatment',
inExperiment: true,
hashAttribute: 'id',
hashValue: 'abc',
featureId: testFlagKey,
},
}));

const res = await ofClient.getNumberDetails(testFlagKey, 1);
expect(res).toEqual({
flagKey: testFlagKey,
flagMetadata: {},
value: 12345,
reason: 'experiment',
variant: 'treatment',
});
});
});

describe('resolveObjectEvaluation', () => {
it('handles correct return types for object variations', async () => {
jest.spyOn(GrowthBookClient.prototype, 'evalFeature').mockImplementation(() => ({
value: { test: true },
source: 'experiment',
on: true,
off: false,
ruleId: 'test',
experimentResult: {
value: { test: true },
variationId: 1,
key: 'treatment',
inExperiment: true,
hashAttribute: 'id',
hashValue: 'abc',
featureId: testFlagKey,
},
}));

const res = await ofClient.getObjectDetails(testFlagKey, {});
expect(res).toEqual({
flagKey: testFlagKey,
flagMetadata: {},
value: { test: true },
reason: 'experiment',
variant: 'treatment',
});
});
});
});
Loading

0 comments on commit 0e6a486

Please sign in to comment.