├── test.txt ├── .vscode └── settings.json ├── .prettierignore ├── .prettierrc ├── src ├── index.ts ├── config │ └── constants.ts ├── interfaces.ts ├── featureFlagStorage.ts └── featureFlagClient.ts ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ ├── pr.yml │ └── publish.yml ├── README.md ├── .eslintrc.json ├── package.json └── tests ├── fixtures ├── flag-config.json └── local-config.json └── featureFlagClient.test.js /test.txt: -------------------------------------------------------------------------------- 1 | hi -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true 3 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | !.eslintrc.json 3 | interfaces.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module wm-feature-flag-client 3 | */ 4 | /** 5 | * Copyright (c) Warner Media. All rights reserved. 6 | */ 7 | export * from './featureFlagClient' 8 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_USER_ID = 'wmAppUserId' 2 | export const FEATURE_FLAG_CONFIG = 'wmFeatureFlagConfig' 3 | export const FEATURE_FLAG_USER_ID = 'wmFeatureFlagUserId' 4 | export const FEATURE_FLAG_CONFIG_ETAG = 'wmFeatureFlagConfigEtag' 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "strict": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "sourceMap": true 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", "**/__tests__/*"] 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | .cache 4 | 5 | # Editor 6 | .vscode/* 7 | !.vscode/settings.json 8 | !.vscode/tasks.json 9 | !.vscode/launch.json 10 | !.vscode/extensions.json 11 | *.code-workspace 12 | 13 | # Filesystem 14 | .DS_Store 15 | 16 | # Logs 17 | npm-debug.log* 18 | 19 | # Optional npm cache directory 20 | .npm 21 | 22 | # build directory 23 | /lib -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - develop 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [12.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Use node.js to install and run unit tests 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm run eslint 23 | - run: npm run test 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This library provides feature flag querying functionality for determining which application features are enabled for a specific user. 2 | 3 | ## Installing 4 | To add the latset published version of this package to your application: 5 | 6 | ```bash 7 | npm install wm-feature-flag-client 8 | ``` 9 | ## Dev setup 10 | ```bash 11 | npm install wm-feature-flag-client 12 | cd wm-feature-flag-client 13 | npm i 14 | npm run build 15 | ``` 16 | 17 | ## Example usage 18 | ``` 19 | const context = { 20 | userId: {USER_ID}, 21 | configUrl: {CONFIG_URL}, 22 | Platform: ["iOS, Windows"], 23 | Brand: ["myBrand"] 24 | } 25 | 26 | const client = new FeatureFlagClient(context) 27 | const featureFlag = await client.queryFeatureFlag('feature-a') 28 | 29 | console.log(featureFlag.enabled) // true or false 30 | ``` 31 | 32 | ## To do 33 | Expanded readme forthcoming 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to GitHub packages 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [12.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Use node.js to install and run unit tests 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm run eslint 23 | - run: npm run test 24 | 25 | publish-gpr: 26 | needs: build 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/setup-node@v1 31 | with: 32 | node-version: 12 33 | registry-url: https://npm.pkg.github.com/ 34 | - run: npm ci 35 | - run: npm publish 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "only-warn"], 4 | "extends": ["plugin:@typescript-eslint/recommended"], 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "ecmaFeatures": { 9 | "impliedStrict": true 10 | } 11 | }, 12 | "rules": { 13 | "semi": ["error", "never"], 14 | "no-return-await": 0, 15 | "space-before-function-paren": [ 16 | "error", 17 | { 18 | "named": "never", 19 | "anonymous": "never", 20 | "asyncArrow": "always" 21 | } 22 | ], 23 | "quotes": ["error", "single", { "allowTemplateLiterals": true }], 24 | "template-curly-spacing": ["error", "never"], 25 | "@typescript-eslint/indent": ["error", 2], 26 | "@typescript-eslint/interface-name-prefix": 0, 27 | "@typescript-eslint/no-explicit-any": 0, 28 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": true }] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IConfig { 2 | featureFlagLibraryVersion?: string; 3 | flags: IFlag[]; 4 | } 5 | 6 | export interface IContext { 7 | userId: string; 8 | configUrl?: string; 9 | [key: string]: any; 10 | } 11 | 12 | export interface IFeatureFlagClient { 13 | queryFeatureFlag(featureName: string): any; 14 | queryAllFeatureFlags(): any; 15 | } 16 | 17 | export interface IFlag { 18 | flagName: string; 19 | flagId: string; 20 | flagType: string; 21 | targeting: any[]; 22 | } 23 | 24 | export interface ILoadConfigResponse { 25 | headers: any; 26 | data: IConfig; 27 | } 28 | 29 | export interface IQueryFeatureResult { 30 | featureName: string; 31 | enabled: boolean; 32 | userId: string | undefined; 33 | userIdType: string; 34 | } 35 | 36 | export interface IStorage { 37 | set(key: string, value: any): any; 38 | get(key: string): any; 39 | delete(key: string): void; 40 | } 41 | 42 | export interface ITargetingConfig { 43 | targetPriority: number; 44 | rolloutValue: string; 45 | stickinessProperty?: string; 46 | targetCriteria?: any[]; 47 | } 48 | 49 | export interface ITargetField { 50 | targetFieldName: string; 51 | targetFieldValues: string[]; 52 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wm-feature-flag-client", 3 | "author": "Warner Media", 4 | "version": "1.0.0", 5 | "description": "A client library for determining the availability of certain platform features for a given user.", 6 | "license": "UNLICENSED", 7 | "private": false, 8 | "main": "./lib/index.js", 9 | "scripts": { 10 | "build": "tsc", 11 | "test": "tsc && mocha ./tests/**/*.js", 12 | "eslint": "eslint ./src/*.ts ./src/**/*.ts", 13 | "eslint-fix": "eslint ./src/*.ts ./src/**/*.ts --fix" 14 | }, 15 | "dependencies": { 16 | "@types/node": "^13.9.2", 17 | "@types/uuid": "^7.0.2", 18 | "axios": "^0.19.2", 19 | "uuid": "^7.0.2", 20 | "winston": "^3.2.1" 21 | }, 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^2.24.0", 24 | "@typescript-eslint/parser": "^2.24.0", 25 | "chai": "^4.2.0", 26 | "eslint": "^6.8.0", 27 | "eslint-config-standard": "^14.1.0", 28 | "eslint-plugin-import": "^2.20.1", 29 | "eslint-plugin-node": "^11.0.0", 30 | "eslint-plugin-only-warn": "^1.0.2", 31 | "eslint-plugin-promise": "^4.2.1", 32 | "eslint-plugin-standard": "^4.0.1", 33 | "mocha": "^7.1.1", 34 | "sinon": "^9.0.1", 35 | "typescript": "^3.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/featureFlagStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module wm-feature-flag-client 3 | */ 4 | /** 5 | * Copyright (c) Warner Media. All rights reserved. 6 | */ 7 | import { IStorage } from './interfaces' 8 | 9 | export class FeatureFlagStorage implements IStorage { 10 | private storageType: string 11 | private storage: any 12 | 13 | constructor(storageType: string) { 14 | this.storageType = storageType 15 | switch (this.storageType) { 16 | case 'localStorage': 17 | this.storage = localStorage 18 | break 19 | case 'sessionStorage': 20 | this.storage = sessionStorage 21 | break 22 | case 'inMemory': // todo: implement an in-memory storage option for non-browser env 23 | default: 24 | this.storage = typeof Storage !== 'undefined' ? localStorage : {} 25 | } 26 | } 27 | 28 | get(key: string): string | void { 29 | if (typeof Storage === 'undefined') return 30 | const storeItem = this.storage.getItem(key) 31 | return storeItem 32 | } 33 | 34 | set(key: string, value: string): void { 35 | if (typeof Storage === 'undefined') return 36 | this.storage.setItem(key, value) 37 | return 38 | } 39 | 40 | delete(key: string): void { 41 | if (typeof Storage === 'undefined') return 42 | this.storage.deleteItem(key) 43 | return 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/fixtures/flag-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testConfig": true, 3 | "featureFlagLibraryVersion":"0.1", 4 | "flags":[ 5 | { 6 | "flagName":"feature-A", 7 | "flagId":"fcff0f33-cde9-4f9a-8e3f-e4981c762da2", 8 | "flagType":"Binary", 9 | "targeting":[ 10 | { 11 | "targetPriority":1, 12 | "rolloutValue":"100", 13 | "stickinessProperty":"appUserId", 14 | "targetCriteria":[ 15 | { 16 | "targetFieldName":"Platform", 17 | "targetFieldValues":["iOS","Android"] 18 | }, 19 | { 20 | "targetFieldName":"Brand", 21 | "targetFieldValues":["BrandA","BrandB"] 22 | } 23 | ] 24 | }, 25 | { 26 | "targetPriority":2, 27 | "rolloutValue":"100", 28 | "stickinessProperty":"appUserId", 29 | "targetCriteria":[ 30 | { 31 | "targetFieldName":"Platform", 32 | "targetFieldValues":["Windows"] 33 | }, 34 | { 35 | "targetFieldName":"Brand", 36 | "targetFieldValues":["BrandE","BrandF"] 37 | } 38 | ] 39 | }, 40 | { 41 | "targetPriority":3, 42 | "rolloutValue":"0", 43 | "stickinessProperty":"appUserId", 44 | "targetCriteria":[] 45 | } 46 | ] 47 | }, 48 | { 49 | "flagName":"feature-B", 50 | "flagId":"gbfg0j66-cde9-7f9f-8e3f-g6981c762da7", 51 | "flagType":"Binary", 52 | "targeting":[ 53 | { 54 | "targetPriority":1, 55 | "rolloutValue":"100", 56 | "stickinessProperty":"appUserId", 57 | "targetCriteria":[ 58 | { 59 | "targetFieldName":"Platform", 60 | "targetFieldValues":["Windows"] 61 | }, 62 | { 63 | "targetFieldName":"Brand", 64 | "targetFieldValues":["BrandC","BrandD"] 65 | } 66 | ] 67 | }, 68 | { 69 | "targetPriority":2, 70 | "rolloutValue":"80", 71 | "stickinessProperty":"appUserId", 72 | "targetCriteria":[ 73 | { 74 | "targetFieldName":"Platform", 75 | "targetFieldValues":["iOS"] 76 | }, 77 | { 78 | "targetFieldName":"Brand", 79 | "targetFieldValues":["BrandG","BrandH"] 80 | } 81 | ] 82 | }, 83 | { 84 | "targetPriority":3, 85 | "rolloutValue":"0", 86 | "stickinessProperty":"appUserId", 87 | "targetCriteria":[] 88 | } 89 | ] 90 | } 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /tests/fixtures/local-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testConfig": true, 3 | "featureFlagLibraryVersion":"0.1", 4 | "flags":[ 5 | { 6 | "flagName":"feature-A", 7 | "flagId":"fcff0f33-cde9-4f9a-8e3f-e4981c762da2", 8 | "flagType":"Binary", 9 | "targeting":[ 10 | { 11 | "targetPriority":1, 12 | "rolloutValue":"100", 13 | "stickinessProperty":"appUserId", 14 | "targetCriteria":[ 15 | { 16 | "targetFieldName":"Platform", 17 | "targetFieldValues":["iOS","Android"] 18 | }, 19 | { 20 | "targetFieldName":"Brand", 21 | "targetFieldValues":["BrandA","BrandB"] 22 | } 23 | ] 24 | }, 25 | { 26 | "targetPriority":2, 27 | "rolloutValue":"100", 28 | "stickinessProperty":"appUserId", 29 | "targetCriteria":[ 30 | { 31 | "targetFieldName":"Platform", 32 | "targetFieldValues":["Windows"] 33 | }, 34 | { 35 | "targetFieldName":"Brand", 36 | "targetFieldValues":["BrandE","BrandF"] 37 | } 38 | ] 39 | }, 40 | { 41 | "targetPriority":3, 42 | "rolloutValue":"0", 43 | "stickinessProperty":"appUserId", 44 | "targetCriteria":[] 45 | } 46 | ] 47 | }, 48 | { 49 | "flagName":"feature-B", 50 | "flagId":"gbfg0j66-cde9-7f9f-8e3f-g6981c762da7", 51 | "flagType":"Binary", 52 | "targeting":[ 53 | { 54 | "targetPriority":1, 55 | "rolloutValue":"100", 56 | "stickinessProperty":"appUserId", 57 | "targetCriteria":[ 58 | { 59 | "targetFieldName":"Platform", 60 | "targetFieldValues":["Windows"] 61 | }, 62 | { 63 | "targetFieldName":"Brand", 64 | "targetFieldValues":["BrandC","BrandD"] 65 | } 66 | ] 67 | }, 68 | { 69 | "targetPriority":2, 70 | "rolloutValue":"80", 71 | "stickinessProperty":"appUserId", 72 | "targetCriteria":[ 73 | { 74 | "targetFieldName":"Platform", 75 | "targetFieldValues":["iOS"] 76 | }, 77 | { 78 | "targetFieldName":"Brand", 79 | "targetFieldValues":["BrandG","BrandH"] 80 | } 81 | ] 82 | }, 83 | { 84 | "targetPriority":3, 85 | "rolloutValue":"0", 86 | "stickinessProperty":"appUserId", 87 | "targetCriteria":[] 88 | } 89 | ] 90 | } 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /tests/featureFlagClient.test.js: -------------------------------------------------------------------------------- 1 | const { expect, assert } = require('chai') 2 | const sinon = require('sinon') 3 | const sandbox = require('sinon').createSandbox() 4 | const { FeatureFlagClient } = require('../lib/index') 5 | const testConfig = require('./fixtures/flag-config.json') 6 | const localTestConfig = require('./fixtures/local-config.json') 7 | 8 | describe('FeatureFlagClient unit tests', function() { 9 | let client, 10 | clientB, 11 | userId = '123' 12 | const config = testConfig 13 | 14 | const context = { 15 | userId: userId, 16 | configUrl: 'testurl', 17 | Platform: ["iOS"], 18 | Brand: ["BrandA"], 19 | configRefreshInterval: 300000 20 | } 21 | 22 | const contextB = { 23 | userId: userId, 24 | configUrl: 'testurl', 25 | Platform: ["Windows"], 26 | Brand: ["BrandB"], 27 | } 28 | 29 | const contextNoConfigUrl = { 30 | userId: '123' 31 | } 32 | 33 | before(async function() { 34 | client = new FeatureFlagClient(context) 35 | clientB = new FeatureFlagClient(contextB) 36 | clientLocalConfig = new FeatureFlagClient(context, localTestConfig) 37 | const loadConfigFake = async () => Promise.resolve({'data': testConfig}) 38 | sandbox.stub(client, 'loadConfig').callsFake(loadConfigFake) 39 | sandbox.stub(clientB, 'loadConfig').callsFake(loadConfigFake) 40 | sandbox.stub(clientLocalConfig, 'loadConfig').callsFake(loadConfigFake) 41 | }) 42 | 43 | after(async function() { 44 | sandbox.restore() 45 | }) 46 | 47 | describe('initialization tests', function() { 48 | it('should throw an error when the context object is not passed', function() { 49 | const initNoArgs = () => new FeatureFlagClient() 50 | expect(initNoArgs).to.throw('Please provide a context object to the constructor.') 51 | }) 52 | 53 | it('should throw an error when neither a configUrl nor a config object is not passed', function() { 54 | const initNoConfigUrl = () => new FeatureFlagClient(contextNoConfigUrl) 55 | expect(initNoConfigUrl).to.throw('Please provide either a config url or a valid config file.') 56 | }) 57 | 58 | it('should not throw an error if either configUrl or config arguments are passed', function() { 59 | expect(() => new FeatureFlagClient(context)).to.not.throw() 60 | expect(() => new FeatureFlagClient(context, config)).to.not.throw() 61 | }) 62 | 63 | it('should check that a new instance of FeatureFlagClient contains the required properties after initialization', function() { 64 | expect(client).to.have.property('context') 65 | expect(client).to.have.property('config') 66 | expect(client).to.have.property('initialized') 67 | expect(client).to.have.property('featureFlagUserId') 68 | expect(client).to.have.property('configCacheStart') 69 | expect(client).to.have.property('configRefreshIntervalDefault') 70 | expect(client).to.have.property('storage') 71 | expect(client.initialized).to.equal(false) 72 | }) 73 | }) 74 | 75 | describe('userId creation tests', async function () { 76 | it('should create and return a new ffUserId when the config file specifies ffUserId', async function () { 77 | const response = await client.queryFeatureFlag('feature-A') 78 | assert.exists(response.userId) 79 | expect(response.userId).to.equal(userId) 80 | expect(response.userIdType).to.equal('appUserId') 81 | }) 82 | }) 83 | 84 | describe('feature flag querying tests', async function () { 85 | it('should succesfully query for "feature-A"', async function () { 86 | const response = await client.queryFeatureFlag('feature-A') 87 | expect(response.enabled).to.equal(true) 88 | expect(response.featureName).to.equal('feature-A') 89 | expect(response.userId).to.equal(userId) 90 | expect(response.userIdType).to.equal('appUserId') 91 | }) 92 | 93 | it('should not match any target criteria and fall through to default for "feature-A"', async function () { 94 | const response = await clientB.queryFeatureFlag('feature-A') 95 | expect(response.enabled).to.equal(false) 96 | expect(response.featureName).to.equal('feature-A') 97 | expect(response.userId).to.equal(userId) 98 | expect(response.userIdType).to.equal('appUserId') 99 | }) 100 | 101 | it('should succesfully query for "feature-B" (appUserId)', async function () { 102 | const response = await client.queryFeatureFlag('feature-B') 103 | expect(response.enabled).to.equal(false) 104 | expect(response.featureName).to.equal('feature-B') 105 | expect(response.userId).to.equal(userId) 106 | expect(response.userIdType).to.equal('appUserId') 107 | }) 108 | 109 | it('should succesfully query for "feature-B" (ffUserId)', async function () { 110 | const response = await clientB.queryFeatureFlag('feature-B') 111 | expect(response.enabled).to.equal(false) 112 | expect(response.featureName).to.equal('feature-B') 113 | expect(response.userId).to.equal(userId) 114 | expect(response.userIdType).to.equal('appUserId') 115 | }) 116 | 117 | it('should succesfully query for all features', async function() { 118 | const response = await client.queryAllFeatureFlags() 119 | expect(response).to.have.lengthOf.at.least(2) 120 | expect(response[0]).to.have.property('enabled') 121 | assert.isBoolean(response[0].enabled) 122 | expect(response[0]).to.have.property('featureName') 123 | expect(response[0]).to.have.property('userId') 124 | expect(response[0].userId).to.equal(userId) 125 | }) 126 | 127 | it('should query from local config file when provided', async function() { 128 | const responseA = await clientLocalConfig.queryFeatureFlag('feature-A') 129 | expect(responseA.enabled).to.equal(true) 130 | expect(responseA.featureName).to.equal('feature-A') 131 | expect(responseA.userId).to.equal(userId) 132 | 133 | const responseB = await clientLocalConfig.queryFeatureFlag('feature-B') 134 | expect(responseB.enabled).to.equal(false) 135 | expect(responseB.featureName).to.equal('feature-B') 136 | expect(responseB.userId).to.equal(userId) 137 | }) 138 | 139 | }) 140 | 141 | describe('feature flag config cache expiration interval tests', function () { 142 | it('should should that the config cache is expired', async function() { 143 | const interval = 0 144 | const isCacheExpired = client.checkConfigCacheExpiry(interval) 145 | expect(isCacheExpired).to.equal(true) 146 | }) 147 | 148 | it('should should that the config cache is not expired', async function() { 149 | const interval = 3600 150 | const isCacheExpired = client.checkConfigCacheExpiry(interval) 151 | expect(isCacheExpired).to.equal(false) 152 | }) 153 | }) 154 | 155 | }) -------------------------------------------------------------------------------- /src/featureFlagClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module wm-feature-flag-client 3 | */ 4 | /** 5 | * Copyright (c) Warner Media. All rights reserved. 6 | */ 7 | import axios from 'axios' 8 | import crypto from 'crypto' 9 | import { v4 as uuidv4 } from 'uuid' 10 | import * as winston from 'winston' 11 | import { IConfig, IContext, IFeatureFlagClient, IFlag, ILoadConfigResponse, IQueryFeatureResult, ITargetingConfig } from './interfaces' 12 | import { FeatureFlagStorage } from './featureFlagStorage' 13 | import { APP_USER_ID, FEATURE_FLAG_CONFIG, FEATURE_FLAG_USER_ID, FEATURE_FLAG_CONFIG_ETAG } from './config/constants' 14 | const logger = winston.createLogger({ 15 | transports: [new winston.transports.Console()], 16 | }) 17 | 18 | /** 19 | * Provides core feature flag querying functionality for 20 | * determining which features are enabled for a specific user. 21 | * @implements {IFlagFlagClient} 22 | */ 23 | export class FeatureFlagClient implements IFeatureFlagClient { 24 | private context: IContext 25 | private config?: IConfig 26 | private featureFlagUserId: string 27 | private initialized: boolean 28 | private configCacheStart: Date 29 | private configRefreshIntervalDefault: number 30 | private storage: FeatureFlagStorage 31 | /** 32 | * Creates a new instance of the [FeatureFlagClient] class. 33 | * @param context The conext object containing the userId and config data 34 | * @param config Optional. The feature flag config file. 35 | * 36 | * @remarks 37 | * If the context.userId property is not provided, one will be generated and returned to the client. 38 | * The config file at location context.configUrl will be fetched from AWS S3 unless a config file is provided 39 | */ 40 | constructor(context: IContext, config?: IConfig) { 41 | if (!context) throw new Error('Please provide a context object to the constructor.') 42 | if (!context.configUrl && !config) throw new Error('Please provide either a config url or a valid config file.') 43 | this.context = context 44 | this.config = config 45 | this.initialized = false 46 | this.featureFlagUserId = '' 47 | this.configCacheStart = new Date() 48 | this.configRefreshIntervalDefault = 86400000 49 | const storageType = context && context.storageType ? context.storageType : '' 50 | this.storage = new FeatureFlagStorage(storageType) 51 | } 52 | 53 | /** 54 | * Creates a hash of the userId and saltKey 55 | * @param userId The userId used to create the hash 56 | * @param salt The salt used to create the hash 57 | * @return {string} hash 58 | */ 59 | private createHash(userId: string, salt: string): string { 60 | const hash = crypto.createHmac('sha256', salt) 61 | hash.update(userId) 62 | const hashValue = hash.digest('hex') 63 | return hashValue 64 | } 65 | 66 | /** 67 | * Creates a user id 68 | * @return {string} userId 69 | */ 70 | private createUserId(): string { 71 | const userId = uuidv4() 72 | return userId 73 | } 74 | 75 | /** 76 | * Creates a 2-digit string from the hash to be used as an index for 77 | * comparing against the rollout percentage of the feature to determine 78 | * whether the feature is enabled or disabled 79 | * @param hash 80 | * @return {string} 2-digit segment of the hash 81 | */ 82 | private getUserFeatureIndex(hash: string): string { 83 | const hashSegment = parseInt(hash.slice(-2), 16) 84 | return hashSegment.toString().slice(-2) 85 | } 86 | 87 | /** 88 | * Initialization code to ensure that the userId and config are set 89 | * @return {void} 90 | */ 91 | private async init(): Promise { 92 | const { config, initialized, featureFlagUserId, storage, configRefreshIntervalDefault } = this 93 | const { configUrl, userId, configRefreshInterval } = this.context 94 | // get user-defined refresh interval or use default if none provided 95 | const cfgRefreshInterval = configRefreshInterval ? configRefreshInterval : configRefreshIntervalDefault 96 | const cacheIsExpired = this.checkConfigCacheExpiry(cfgRefreshInterval) 97 | 98 | // initialization steps: 99 | // 1. ensure we have feature flag configuration. if none provided in context object, 100 | // look for config in storage, otherwise fetch it from configUrl location 101 | if (!config || cacheIsExpired) { 102 | try { 103 | if (!configUrl) throw new Error('Failed to load config file - no config url provided.') 104 | const configFromStorage = storage.get(FEATURE_FLAG_CONFIG) 105 | if (configFromStorage) this.config = JSON.parse(configFromStorage) 106 | if (!configFromStorage || cacheIsExpired) { 107 | // check if config file has changed before downloading 108 | // by populating 'If-None-Match' header with value of the previous etag 109 | // get config file (if it has been updated), save it in storage along with new eTag 110 | const prevETag = storage.get(FEATURE_FLAG_CONFIG_ETAG) || '-1' 111 | const response = await this.loadConfig(configUrl, prevETag) 112 | this.config = response.data ? response.data : this.config 113 | const eTag = (response.headers && response.headers.etag) ? response.headers.etag : '' 114 | if (eTag) storage.set(FEATURE_FLAG_CONFIG_ETAG, eTag) 115 | storage.set(FEATURE_FLAG_CONFIG, JSON.stringify(this.config)) 116 | } 117 | } catch (err) { 118 | logger.error(err) 119 | } 120 | } 121 | 122 | // pass through if instance already initialized 123 | if (initialized) return 124 | 125 | // 2. ensure we have a userId. if it's not in the context object, check storage. 126 | if (!userId) { 127 | const userId = storage.get(APP_USER_ID) 128 | if (userId) this.context.userId = userId 129 | } else { 130 | storage.set(APP_USER_ID, userId) 131 | } 132 | 133 | // 3. ensure we have a featureFlagUserId. if there isn't one in storage, create one. 134 | if (!featureFlagUserId) { 135 | const ffUserId = storage.get(FEATURE_FLAG_USER_ID) 136 | if (!ffUserId) { 137 | this.featureFlagUserId = this.createUserId() 138 | storage.set(FEATURE_FLAG_USER_ID, this.featureFlagUserId) 139 | } 140 | } 141 | 142 | this.initialized = true 143 | } 144 | 145 | /** 146 | * Determine whether the flag config has been cached longer than the desired interval 147 | * @param interval The maximum time (in ms) to cache the config file before fetching a new copy 148 | * @return {boolean} 149 | */ 150 | checkConfigCacheExpiry(interval: number): boolean { 151 | const { configCacheStart } = this 152 | const now = new Date() 153 | const cacheTimeElapsed = (now.getTime() - configCacheStart.getTime()) // in ms 154 | const expired = (cacheTimeElapsed > interval) ? true : false 155 | return expired 156 | } 157 | 158 | /** 159 | * Loads the config file from the configUrl location 160 | * @param configUrl Uri for the desired feature flag config file 161 | * @param eTag The value of the etag header from the previous request, default to -1 162 | */ 163 | async loadConfig(configUrl: string, eTag = '-1'): Promise { 164 | let response 165 | const headers = { 'If-None-Match': eTag } 166 | try { 167 | response = await axios.get(configUrl, { headers }) 168 | } catch (error) { 169 | throw new Error('Failed to retrieve config file.') 170 | } 171 | return response 172 | } 173 | 174 | /** 175 | * Determine which targeting configuration from the feature config file to use 176 | * @param context The conext object containing the userId and config data 177 | * @param targetingConfigs targeting configuration objects from the feature config file 178 | * @return {ITargetingConfig} 179 | */ 180 | getTargetingConfig(context: IContext, targetingConfigs: ITargetingConfig[]): ITargetingConfig { 181 | // set a defaut targeting config in case no matches found 182 | let targetingConfig: ITargetingConfig = { rolloutValue: '0', targetPriority: 1 } 183 | 184 | // sort by 'targetPriority' property 185 | const targetConfigsSorted = [...targetingConfigs] 186 | targetConfigsSorted.sort((a, b) => (a.targetPriority > b.targetPriority ? 1 : -1)) 187 | 188 | // identify targeting config to use by validating all 'targetCriteria' fields 189 | const matchedTargetingConfig = targetConfigsSorted.find(targetConfig => { 190 | let matchDetected = false 191 | if (!targetConfig.targetCriteria) return 192 | const targetFields = targetConfig.targetCriteria 193 | // iterate through target criteria 194 | for (let i = 0; i < targetFields.length; i++) { 195 | const { targetFieldName, targetFieldValues } = targetFields[i] 196 | // return early if there's no targeting info 197 | if (!targetFieldName || !targetFieldValues || !context[targetFieldName]) return 198 | // if at least one target field value is present in context object, consider the filed validated 199 | const targetFieldFoundInContext = targetFieldValues.some((fieldValue: string) => { 200 | // check for both string and array values of the matching property 201 | if (typeof context[targetFieldName] === 'string') return context[targetFieldName] === fieldValue 202 | if (Array.isArray(context[targetFieldName])) { 203 | return context[targetFieldName].some((contextValue: string) => contextValue === fieldValue) 204 | } 205 | }) 206 | matchDetected = targetFieldFoundInContext 207 | if (!matchDetected) break 208 | } 209 | return matchDetected 210 | }) 211 | 212 | // overwrite default targeting config if a matching config was found 213 | if (matchedTargetingConfig) targetingConfig = { ...matchedTargetingConfig } 214 | return targetingConfig 215 | } 216 | 217 | /** 218 | * Checks whether or not a given feature is enabled for a specific user 219 | * @param featureName 220 | * @return {IQueryFeatureResult} response object containing feature name, 'enabled' boolean and userId 221 | */ 222 | async queryFeatureFlag(featureName: string): Promise { 223 | const { context, storage } = this 224 | let enabled = false, 225 | flagConfig: any = [], 226 | hashId, 227 | operationalId, 228 | userFeatureIndex: string, 229 | userIdType = 'appUserId' 230 | try { 231 | await this.init() 232 | operationalId = this.context.userId 233 | if (!this.config || !this.config.flags) 234 | throw new Error('Operation failed - no config file or invalid config file detected.') 235 | 236 | // get the feature flag configuration matching the feature name provided 237 | flagConfig = this.config.flags.find((flag: IFlag) => flag.flagName === featureName) 238 | const { targeting } = flagConfig 239 | 240 | // determine the feature's targeting config 241 | const targetingConfig = this.getTargetingConfig(context, targeting) 242 | let { rolloutValue } = targetingConfig 243 | const { stickinessProperty } = targetingConfig 244 | rolloutValue = rolloutValue || '0' 245 | 246 | // create the hash and 'user feature index' (derived from hash) 247 | const saltKey = flagConfig.flagId 248 | hashId = this.context.userId 249 | if (stickinessProperty === 'ffUserId') { 250 | let ffUserId = storage.get(FEATURE_FLAG_USER_ID) 251 | if (!ffUserId) { 252 | ffUserId = this.createUserId() 253 | storage.set(FEATURE_FLAG_USER_ID, ffUserId) 254 | } 255 | operationalId = this.featureFlagUserId 256 | userIdType = 'ffUserId' 257 | hashId = ffUserId 258 | } 259 | if (!hashId) throw new Error('Operation failed - userId not provided') 260 | const hash = this.createHash(hashId, saltKey) 261 | userFeatureIndex = this.getUserFeatureIndex(hash) 262 | 263 | // determine whether or not flag is enabled for the given user 264 | enabled = parseInt(userFeatureIndex, 10) < parseInt(rolloutValue, 10) ? true : false 265 | } catch (err) { 266 | logger.error(err) 267 | } 268 | 269 | return { 270 | featureName: featureName, 271 | enabled: enabled, 272 | userId: operationalId, 273 | userIdType: userIdType, 274 | } 275 | } 276 | 277 | /** 278 | * Checks availability of all features for a specific user 279 | * @return {IQueryFeatureResult[]} array of feature flag data objects 280 | */ 281 | async queryAllFeatureFlags(): Promise { 282 | await this.init() 283 | const { config } = this 284 | if (!config || !config.flags) throw new Error('No config file or invalid config file detected.') 285 | const featureFlagResultsMap: IQueryFeatureResult[] = [] 286 | const promises = config.flags.map(async (flag: IFlag) => { 287 | const featureFlagData: IQueryFeatureResult = await this.queryFeatureFlag(flag.flagName) 288 | featureFlagResultsMap.push(featureFlagData) 289 | }) 290 | return Promise.all(promises).then(() => featureFlagResultsMap) 291 | } 292 | } 293 | --------------------------------------------------------------------------------