From 9cbf397956a5daf7551acfd391400d8788a9ff91 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 2 May 2020 11:01:58 -0700 Subject: [PATCH 01/15] Improved snapshot reading - Added snapshot changes watch - Changed snapshotLocation to look at folder --- LICENSE | 2 +- package.json | 2 +- readme.md | 8 ++-- src/index.d.ts | 11 ++++- src/index.js | 52 +++++++++++++++++------ src/utils/index.js | 2 +- test/playground/index.js | 22 +++++++--- test/playground/snapshot/default.json | 59 +++++++++++++++++++++++++++ test/playground/snapshot/dev.json | 59 +++++++++++++++++++++++++++ test/test.js | 28 ++++++++----- 10 files changed, 209 insertions(+), 36 deletions(-) create mode 100644 test/playground/snapshot/default.json create mode 100644 test/playground/snapshot/dev.json diff --git a/LICENSE b/LICENSE index 9dc814a..6f2b603 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Rogerio F. da Cunha (petruki) and Contributors +Copyright (c) 2019 Roger Floriano (petruki) and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index 3c055da..20ef696 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./src/index.js", "types": "./src/index.d.ts", "author": { - "name": "Rogerio Petruki", + "name": "Roger Floriano", "email": "switcher.project@gmail.com" }, "keywords": [ diff --git a/readme.md b/readme.md index f8a9e4a..334ee6c 100644 --- a/readme.md +++ b/readme.md @@ -23,13 +23,13 @@ Switcher Client is a friendly lib to interact with the Switcher API by: - Being secure by using OAuth 2 flow. Requests are made using tokens that will validate your domain, component, environment and API key. Tokens have an expiration time and are not stored. The Switcher Client is responsible to renew it using your settings. -# Example +# Usage 1) Configure your client ```js const Switcher = require("switcher-client"); const apiKey = 'API Key'; -const environment = 'default'; +const environment = 'default'; // Production = default const domain = 'Your Domain Name'; const component = 'Android'; const url = 'http://localhost:3000/criteria'; @@ -43,12 +43,12 @@ const url = 'http://localhost:3000/criteria'; 2) Options - you can also activate features such as offline and silent mode ```js const offline = true; -const snapshotLocation = './snapshot/default.json'; +const snapshotLocation = './snapshot/'; const silentMode = true; const retryAfter = '5m'; ``` - **offline**: If activated, the client will only fetch the configuration inside your snapshot file. The default value is 'false'. -- **snapshotLocation**: Location of your snapshot. The default value is './snapshot/default.json'. +- **snapshotLocation**: Location of snapshot files. The default value is './snapshot/'. - **silentMode**: If activated, all connections errors will be ignored and the client will automatically fetch the configuration into your snapshot. - **retryAfter** : Set the duration you want the client to try to reach the online API again. (see moment documentation for time signature). The default value is 5m. diff --git a/src/index.d.ts b/src/index.d.ts index c6d857b..9d00c9d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,6 +1,12 @@ declare class Switcher { - constructor(url: string, apiKey: string, domain: string, component: string, environment: string, options?: SwitcherOptions); + constructor( + url: string, + apiKey: string, + domain: string, + component: string, + environment: string, + options?: SwitcherOptions); url: string; token: string; @@ -11,6 +17,7 @@ declare class Switcher { input: string[]; exp: number; bypassedKeys: Key; + snapshot?: string; validate(): void; prepare(key: string, input?: string[]): void; @@ -18,6 +25,8 @@ declare class Switcher { isItOnPromise(key?: string, input?: string[]): Promise; assume(key: string): Key; forget(key: string): void; + loadSnapshot(): void; + unloadSnapshot(): void; } declare interface SwitcherOptions { diff --git a/src/index.js b/src/index.js index 106de4f..8f76a01 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,12 @@ "use strict"; -const services = require('./utils/services') -const { loadDomain, processOperation, StrategiesType } = require('./utils/index') +const services = require('./utils/services'); +const { loadDomain, processOperation, StrategiesType } = require('./utils/index'); +const fs = require('fs'); + +const DEFAULT_SNAPSHOT_LOCATION = './snapshot/'; +const DEFAULT_RETRY_TIME = '5m'; +const DEFAULT_OFFLINE = false; class Switcher { @@ -13,8 +18,8 @@ class Switcher { this.environment = environment; // Default values - this.offline = false; - this.snapshotLocation = './snapshot/default.json'; + this.offline = DEFAULT_OFFLINE; + this.snapshotLocation = DEFAULT_SNAPSHOT_LOCATION; if (options) { if ('offline' in options) { @@ -33,11 +38,12 @@ class Switcher { this.retryTime = options.retryAfter.slice(0, -1); this.retryDurationIn = options.retryAfter.slice(-1); } else { - this.retryTime = 5; - this.retryDurationIn = 'm'; + this.retryTime = DEFAULT_RETRY_TIME.charAt(0); + this.retryDurationIn = DEFAULT_RETRY_TIME.charAt(1); } } + this.loadSnapshot(); this.bypassedKeys = new Array(); } @@ -70,11 +76,11 @@ class Switcher { } if (!this.key) { - errors.push('Missing key field'); + errors.push('Missing key field'); } if (!this.url) { - errors.push('Missing url field'); + errors.push('Missing url field'); } if (!this.exp || Date.now() > (this.exp*1000)) { @@ -86,7 +92,7 @@ class Switcher { } if (errors.length) { - throw new Error(`Something went wrong: ${errors.join(', ')}`) + throw new Error(`Something went wrong: ${errors.join(', ')}`); } } @@ -98,7 +104,7 @@ class Switcher { if (this.offline) { return await checkCriteriaOffline( - this.key ? this.key : key, this.input ? this.input : input, this.snapshotLocation); + this.key ? this.key : key, this.input ? this.input : input, this.snapshot); } if (key) { this.key = key; } @@ -107,7 +113,7 @@ class Switcher { await this.validate(); if (this.token === 'SILENT') { return await checkCriteriaOffline( - this.key ? this.key : key, this.input ? this.input : input, this.snapshotLocation); + this.key ? this.key : key, this.input ? this.input : input, this.snapshot); } else { return await services.checkCriteria(this.url, this.token, this.key, this.input); } @@ -133,6 +139,26 @@ class Switcher { this.bypassedKeys.splice(this.bypassedKeys.indexOf(searchBypassed(key, this.bypassedKeys)), 1); } + loadSnapshot() { + if (this.snapshotLocation) { + const snapshotFile = `${this.snapshotLocation}${this.environment}.json`; + this.snapshot = loadDomain(snapshotFile); + + fs.unwatchFile(snapshotFile); + fs.watchFile(snapshotFile, (curr, prev) => { + this.snapshot = loadDomain(snapshotFile); + }); + } + } + + unloadSnapshot() { + if (this.snapshotLocation) { + const snapshotFile = `${this.snapshotLocation}${this.environment}.json`; + this.snapshot = undefined; + fs.unwatchFile(snapshotFile); + } + } + static get StrategiesType() { return StrategiesType; } @@ -172,8 +198,8 @@ function searchBypassed(key, bypassedKeys) { return existentKey; } -async function checkCriteriaOffline(key, input, snapshotLocation) { - const { data } = await loadDomain(snapshotLocation); +async function checkCriteriaOffline(key, input, snapshot) { + const { data } = snapshot; return await resolveCriteria(key, input, data); } diff --git a/src/utils/index.js b/src/utils/index.js index e786e95..c8ec206 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,4 +1,4 @@ -const fs = require('fs') +const fs = require('fs'); const moment = require('moment'); const IPCIDR = require('ip-cidr'); diff --git a/test/playground/index.js b/test/playground/index.js index aa6b2e7..419e56d 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -1,20 +1,30 @@ const Switcher = require('../../src/index') -let switcher = new Switcher(); +let switcher; function setupSwitcher() { const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S'; const domain = 'My Domain'; const component = 'Android'; const environment = 'default'; - const url = 'http://localhost:3000' + const url = 'http://localhost:3000'; switcher = new Switcher(url, apiKey, domain, component, environment, { - offline: false + offline: true }) } -const main = async () => { +const test2 = async () => { + setupSwitcher(); + + await new Promise(resolve => setTimeout(resolve, 10000)); + let result = await switcher.isItOn('FF2FOR2030'); + console.log(result); + + switcher.unloadSnapshot(); +} + +const test1 = async () => { setupSwitcher(); let result = await switcher.isItOn('FEATURE2020'); @@ -27,6 +37,8 @@ const main = async () => { switcher.assume('FEATURE2020').false(); result = await switcher.isItOn('FEATURE2020'); console.log('Value changed:', result); + + switcher.unloadSnapshot(); } -main() \ No newline at end of file +test2() \ No newline at end of file diff --git a/test/playground/snapshot/default.json b/test/playground/snapshot/default.json new file mode 100644 index 0000000..13f45e4 --- /dev/null +++ b/test/playground/snapshot/default.json @@ -0,0 +1,59 @@ +{ + "data": { + "domain": { + "name": "currency-api", + "description": "Currency API", + "activated": true, + "group": [ + { + "name": "Rollout 2020", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2020", + "description": "Feature Flag", + "activated": true, + "strategies": [ + { + "strategy": "NETWORK_VALIDATION", + "activated": true, + "operation": "EXIST", + "values": [ + "10.0.0.3/24" + ] + }, + { + "strategy": "VALUE_VALIDATION", + "activated": true, + "operation": "NOT_EXIST", + "values": [ + "USA", + "Canada", + "Australia", + "Africa" + ] + } + ], + "components": [] + } + ] + }, + { + "name": "Rollout 2030", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2030", + "description": "Feature Flag", + "activated": true, + "strategies": [], + "components": [] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/playground/snapshot/dev.json b/test/playground/snapshot/dev.json new file mode 100644 index 0000000..1b66fab --- /dev/null +++ b/test/playground/snapshot/dev.json @@ -0,0 +1,59 @@ +{ + "data": { + "domain": { + "name": "currency-api", + "description": "Currency API", + "activated": true, + "group": [ + { + "name": "Rollout 2020", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2020", + "description": "Feature Flag", + "activated": true, + "strategies": [ + { + "strategy": "NETWORK_VALIDATION", + "activated": true, + "operation": "EXIST", + "values": [ + "10.0.0.3/24" + ] + }, + { + "strategy": "VALUE_VALIDATION", + "activated": true, + "operation": "NOT_EXIST", + "values": [ + "USA", + "Canada", + "Australia", + "Africa" + ] + } + ], + "components": [] + } + ] + }, + { + "name": "Rollout 2030", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2030", + "description": "Feature Flag", + "activated": false, + "strategies": [], + "components": [] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/test.js b/test/test.js index 02103af..62779a6 100644 --- a/test/test.js +++ b/test/test.js @@ -5,10 +5,11 @@ const sinon = require('sinon'); const Switcher = require('../src/index') const request = require('request-promise') const services = require('../src/utils/services') +const fs = require('fs'); // const { StrategiesType } = require('../src/utils/index') describe('E2E test - Switcher offline:', function () { - let switcher = new Switcher(); + let switcher; const apiKey = '$2b$08$S2Wj/wG/Rfs3ij0xFbtgveDtyUAjML1/TOOhocDg5dhOaU73CEXfK'; const domain = 'currency-api'; const component = 'Android'; @@ -21,6 +22,10 @@ describe('E2E test - Switcher offline:', function () { }) }) + this.afterAll(function() { + fs.unwatchFile('./snapshot/default.json'); + }) + it('Should be valid', async function () { await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) await switcher.isItOn('FF2FOR2020').then(function (result) { @@ -80,20 +85,23 @@ describe('E2E test - Switcher offline:', function () { }) it('Should be invalid - Offline file not found', async function () { - const offlineSwitcher = new Switcher(url, apiKey, domain, component, environment, { - offline: true, - snapshotLocation: 'somewhere/snapshot.json' - }) - - await offlineSwitcher.isItOn('FF2FOR2020').then(function (result) { - }, function (error) { - assert.equal('Something went wrong: It was not possible to load the file at somewhere/snapshot.json', error.message) - }) + try { + new Switcher(url, apiKey, domain, component, environment, { + offline: true, + snapshotLocation: 'somewhere/' + }) + } catch (error) { + assert.equal('Something went wrong: It was not possible to load the file at somewhere/default.json', error.message) + } }) }) describe('Unit test - Switcher:', function () { + this.afterAll(function() { + fs.unwatchFile('./snapshot/default.json'); + }) + describe('check criteria:', function () { let requestStub; From 31cdce7665879b3b6098b5e771e753048e348723 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 2 May 2020 12:42:45 -0700 Subject: [PATCH 02/15] Decoupled lib to better maintainability --- src/index.js | 91 ++------------------------------------------- src/lib/bypasser.js | 37 ++++++++++++++++++ src/lib/resolver.js | 60 ++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 src/lib/bypasser.js create mode 100644 src/lib/resolver.js diff --git a/src/index.js b/src/index.js index 8f76a01..9872bed 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ "use strict"; const services = require('./utils/services'); -const { loadDomain, processOperation, StrategiesType } = require('./utils/index'); +const { loadDomain, StrategiesType } = require('./utils/index'); +const { Key, searchBypassed } = require('./lib/bypasser'); +const checkCriteriaOffline = require('./lib/resolver'); const fs = require('fs'); const DEFAULT_SNAPSHOT_LOCATION = './snapshot/'; @@ -165,91 +167,4 @@ class Switcher { } -class Key { - constructor(key) { - this.key = key; - this.value = undefined; - } - - true() { - this.value = true; - } - - false() { - this.value = false; - } - - getKey() { - return this.key; - } - - getValue() { - return this.value; - } -} - -function searchBypassed(key, bypassedKeys) { - let existentKey; - bypassedKeys.forEach(async bk => { - if (bk.getKey() === key) { - return existentKey = bk; - } - }) - return existentKey; -} - -async function checkCriteriaOffline(key, input, snapshot) { - const { data } = snapshot; - return await resolveCriteria(key, input, data); -} - -async function resolveCriteria(key, input, { domain }) { - if (!domain.activated) { - return false; - } - - let result = true; - let notFoundKey = true; - const { group } = domain; - if (group) { - group.forEach(function (g) { - - const { config } = g; - const configFound = config.filter(c => c.key === key) - - if (configFound[0]) { - notFoundKey = false; - if (!g.activated) { - return result = false; - } - - if (!configFound[0].activated) { - return result = false; - } - - const { strategies } = configFound[0] - strategies.forEach(function(strategy) { - if (strategy.activated) { - if (!input) { - return result = false; - } - - const entry = services.getEntry(input); - const entryInput = entry.filter(e => e.strategy === strategy.strategy) - if (!processOperation(strategy.strategy, strategy.operation, entryInput[0].input, strategy.values)) { - return result = false; - } - } - }) - } - }) - } - - if (notFoundKey) { - throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`) - } - - return result; -} - module.exports = Switcher; \ No newline at end of file diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js new file mode 100644 index 0000000..55914ef --- /dev/null +++ b/src/lib/bypasser.js @@ -0,0 +1,37 @@ +class Key { + constructor(key) { + this.key = key; + this.value = undefined; + } + + true() { + this.value = true; + } + + false() { + this.value = false; + } + + getKey() { + return this.key; + } + + getValue() { + return this.value; + } +} + +function searchBypassed(key, bypassedKeys) { + let existentKey; + bypassedKeys.forEach(async bk => { + if (bk.getKey() === key) { + return existentKey = bk; + } + }) + return existentKey; +} + +module.exports = { + Key, + searchBypassed +} \ No newline at end of file diff --git a/src/lib/resolver.js b/src/lib/resolver.js new file mode 100644 index 0000000..b1dfd05 --- /dev/null +++ b/src/lib/resolver.js @@ -0,0 +1,60 @@ +const { processOperation } = require('../utils/index'); +const services = require('../utils/services'); + +async function resolveCriteria(key, input, { + domain +}) { + if (!domain.activated) { + return false; + } + + let result = true; + let notFoundKey = true; + const { group } = domain; + if (group) { + group.forEach(function (g) { + + const { config } = g; + const configFound = config.filter(c => c.key === key) + + if (configFound[0]) { + notFoundKey = false; + if (!g.activated) { + return result = false; + } + + if (!configFound[0].activated) { + return result = false; + } + + const { strategies } = configFound[0] + strategies.forEach(function (strategy) { + if (strategy.activated) { + if (!input) { + return result = false; + } + + const entry = services.getEntry(input); + const entryInput = entry.filter(e => e.strategy === strategy.strategy) + if (!processOperation(strategy.strategy, strategy.operation, entryInput[0].input, strategy.values)) { + return result = false; + } + } + }) + } + }) + } + + if (notFoundKey) { + throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`) + } + + return result; +} + +async function checkCriteriaOffline(key, input, snapshot) { + const { data } = snapshot; + return await resolveCriteria(key, input, data); +} + +module.exports = checkCriteriaOffline; \ No newline at end of file From f47c1fc4ddfde5dd374c37f18d14e3a9f9523b9f Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Tue, 5 May 2020 23:28:00 -0700 Subject: [PATCH 03/15] Added snapshot update feature --- snapshot/dev.json | 26 ++++++ src/index.d.ts | 37 ++++++++ src/index.js | 25 +++++- src/lib/resolver.js | 2 +- src/{utils => lib}/services.js | 73 ++++++++++++++-- src/lib/snapshot.js | 16 ++++ test/playground/index.js | 47 ++++++----- test/playground/snapshot/default.json | 57 ++++++++----- test/playground/snapshot/dev.json | 35 +------- test/test.js | 116 ++++++++++++++++++++++++-- 10 files changed, 342 insertions(+), 92 deletions(-) create mode 100644 snapshot/dev.json rename src/{utils => lib}/services.js (55%) create mode 100644 src/lib/snapshot.js diff --git a/snapshot/dev.json b/snapshot/dev.json new file mode 100644 index 0000000..a83c8ba --- /dev/null +++ b/snapshot/dev.json @@ -0,0 +1,26 @@ +{ + "data": { + "domain": { + "name": "currency-api", + "description": "Currency API", + "version": 1588557288037, + "activated": true, + "group": [ + { + "name": "Rollout 2030", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2030", + "description": "Feature Flag", + "activated": false, + "strategies": [], + "components": [] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/src/index.d.ts b/src/index.d.ts index 9d00c9d..8054412 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -19,13 +19,50 @@ declare class Switcher { bypassedKeys: Key; snapshot?: string; + /** + * Validate the input provided to access the API + */ validate(): void; + + /** + * Pre-set input values before calling the API + * + * @param key + * @param input + */ prepare(key: string, input?: string[]): void; + isItOn(key?: string, input?: string[]): boolean; isItOnPromise(key?: string, input?: string[]): Promise; + + /** + * Force a switcher value to return a given value by calling one of both methods - true() false() + * + * @param key + */ assume(key: string): Key; + + /** + * Remove forced value from a switcher + * + * @param key + */ forget(key: string): void; + + /** + * Read snapshot file locally and store in a parsed JSON object + */ loadSnapshot(): void; + + /** + * Verifies if the current snapshot file is updated. + * Return true if an update has been made. + */ + checkSnapshot(): boolean; + + /** + * Remove snapshot from real-time update + */ unloadSnapshot(): void; } diff --git a/src/index.js b/src/index.js index 9872bed..eb1b41c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,10 @@ "use strict"; -const services = require('./utils/services'); const { loadDomain, StrategiesType } = require('./utils/index'); const { Key, searchBypassed } = require('./lib/bypasser'); +const services = require('./lib/services'); const checkCriteriaOffline = require('./lib/resolver'); +const validateSnapshot = require('./lib/snapshot'); const fs = require('fs'); const DEFAULT_SNAPSHOT_LOCATION = './snapshot/'; @@ -121,6 +122,28 @@ class Switcher { } } + async checkSnapshot() { + if (!this.exp || Date.now() > (this.exp*1000)) { + const response = await services.auth(this.url, this.apiKey, this.domain, this.component, this.environment, { + silentMode: this.silentMode, + retryTime: this.retryTime, + retryDurationIn: this.retryDurationIn + }); + + this.token = response.token; + this.exp = response.exp; + + const result = await validateSnapshot( + this.url, this.token, this.domain, this.environment, this.snapshotLocation, this.snapshot.data.domain.version); + + if (result) { + this.loadSnapshot(); + return true; + } + } + return false; + } + isItOnPromise(key, input) { return new Promise((resolve) => resolve(this.isItOn(key, input))); } diff --git a/src/lib/resolver.js b/src/lib/resolver.js index b1dfd05..0e3575f 100644 --- a/src/lib/resolver.js +++ b/src/lib/resolver.js @@ -1,5 +1,5 @@ const { processOperation } = require('../utils/index'); -const services = require('../utils/services'); +const services = require('../lib/services'); async function resolveCriteria(key, input, { domain diff --git a/src/utils/services.js b/src/lib/services.js similarity index 55% rename from src/utils/services.js rename to src/lib/services.js index 4867b03..29a137b 100644 --- a/src/utils/services.js +++ b/src/lib/services.js @@ -48,10 +48,8 @@ exports.checkCriteria = async (url, token, key, input) => { let error if (e.error) { error = JSON.stringify(e.error) - } else { - error = e.message } - throw new CriteriaError(error) + throw new CriteriaError(e.error ? error : e.message) } } @@ -85,11 +83,66 @@ exports.auth = async (url, apiKey, domain, component, environment, options) => { let error if (e.error) { error = JSON.stringify(e.error) - } else { - error = e.message } + throw new AuthError(e.error ? error : e.message) + } +} + +exports.checkSnapshotVersion = async (url, token, version) => { + try { + const options = { + url: `${url}/criteria/snapshot_check/${version}`, + headers: { + 'Authorization': `Bearer ${token}` + }, + json: true + } + + const response = await request.get(options); + return response; + } catch (e) { + let error + if (e.error) { + error = JSON.stringify(e.error) + } + throw new SnapshotServiceError(e.error ? error : e.message) + } +} + +exports.resolveSnapshot = async (url, token, domain, environment) => { + var query = `query domain($domain: String!, $environment: String!) { + domain(name: $domain, environment: $environment) { + name version activated + group { name activated + config { key activated + strategies { strategy activated operation values } + components + } + } + } + }`; - throw new AuthError(error) + try { + const options = { + url: `${url}/graphql`, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + query, + variables: { domain, environment } + }) + } + + const response = await request.post(options); + return JSON.stringify(JSON.parse(response), null, 4); + } catch (e) { + let error + if (e.error) { + error = JSON.stringify(e.error) + } + throw new SnapshotServiceError(e.error ? error : e.message) } } @@ -107,4 +160,12 @@ class CriteriaError extends Error { this.name = this.constructor.name Error.captureStackTrace(this, this.constructor) } +} + +class SnapshotServiceError extends Error { + constructor(message) { + super(`Something went wrong: ${message}`) + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + } } \ No newline at end of file diff --git a/src/lib/snapshot.js b/src/lib/snapshot.js new file mode 100644 index 0000000..5e8b629 --- /dev/null +++ b/src/lib/snapshot.js @@ -0,0 +1,16 @@ +const { resolveSnapshot, checkSnapshotVersion } = require('./services'); +const fs = require('fs'); + +async function validateSnapshot(url, token, domain, environment, snapshotLocation, snapshotVersion) { + const { status } = await checkSnapshotVersion(url, token, snapshotVersion) + + if (!status) { + const snapshot = await resolveSnapshot(url, token, domain, environment) + + fs.writeFileSync(`${snapshotLocation}${environment}.json`, snapshot) + return true + } + return false +} + +module.exports = validateSnapshot; \ No newline at end of file diff --git a/test/playground/index.js b/test/playground/index.js index 419e56d..0eeb36a 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -1,44 +1,51 @@ const Switcher = require('../../src/index') -let switcher; +let switcher function setupSwitcher() { - const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S'; - const domain = 'My Domain'; - const component = 'Android'; - const environment = 'default'; - const url = 'http://localhost:3000'; + const apiKey = '$2b$08$7U/KJBVgG.FQtYEKKnbLe.o6p7vBrfHFRgMipZTaokSmVFiduXq/y' + // const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S' + const domain = 'My Domain' + const component = 'Android' + const environment = 'default' + const url = 'http://localhost:3000' switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true }) } -const test2 = async () => { - setupSwitcher(); +// Requires online API +const testSnapshotUpdate = async () => { + setupSwitcher() + + let result = await switcher.isItOn('FEATURE2020') + console.log(result) + + await switcher.checkSnapshot() + await new Promise(resolve => setTimeout(resolve, 1000)) - await new Promise(resolve => setTimeout(resolve, 10000)); - let result = await switcher.isItOn('FF2FOR2030'); - console.log(result); + result = await switcher.isItOn('FEATURE2020') + console.log(result) - switcher.unloadSnapshot(); + switcher.unloadSnapshot() } -const test1 = async () => { +const testAsyncCall = async () => { setupSwitcher(); let result = await switcher.isItOn('FEATURE2020'); - console.log(result); + console.log(result) switcher.isItOnPromise('FEATURE2020') .then(result => console.log('Promise result:', result)) - .catch(error => console.log(error)); + .catch(error => console.log(error)) - switcher.assume('FEATURE2020').false(); - result = await switcher.isItOn('FEATURE2020'); - console.log('Value changed:', result); + switcher.assume('FEATURE2020').false() + result = await switcher.isItOn('FEATURE2020') + console.log('Value changed:', result) - switcher.unloadSnapshot(); + switcher.unloadSnapshot() } -test2() \ No newline at end of file +testAsyncCall() \ No newline at end of file diff --git a/test/playground/snapshot/default.json b/test/playground/snapshot/default.json index 13f45e4..f06cdcf 100644 --- a/test/playground/snapshot/default.json +++ b/test/playground/snapshot/default.json @@ -1,37 +1,43 @@ { "data": { "domain": { - "name": "currency-api", - "description": "Currency API", + "name": "My Domain", + "version": 1588557288037, "activated": true, "group": [ { - "name": "Rollout 2020", - "description": "Changes that will be applied during the rollout", + "name": "Project 1", "activated": true, "config": [ { - "key": "FF2FOR2020", - "description": "Feature Flag", - "activated": true, + "key": "FEATURE01", + "activated": false, "strategies": [ { - "strategy": "NETWORK_VALIDATION", + "strategy": "VALUE_VALIDATION", "activated": true, "operation": "EXIST", "values": [ - "10.0.0.3/24" + "TEST" ] - }, + } + ], + "components": [ + "5e0ecf7d6f4f994eac9007b6", + "5e31372fe8533b5944d4e408", + "5e30f1898db9c44460d22770" + ] + }, + { + "key": "FEATURE02", + "activated": true, + "strategies": [ { "strategy": "VALUE_VALIDATION", "activated": true, - "operation": "NOT_EXIST", + "operation": "EQUAL", "values": [ - "USA", - "Canada", - "Australia", - "Africa" + "USA" ] } ], @@ -40,16 +46,25 @@ ] }, { - "name": "Rollout 2030", - "description": "Changes that will be applied during the rollout", + "name": "Project 2", "activated": true, "config": [ { - "key": "FF2FOR2030", - "description": "Feature Flag", + "key": "FEATURE2020", "activated": true, - "strategies": [], - "components": [] + "strategies": [ + { + "strategy": "NETWORK_VALIDATION", + "activated": false, + "operation": "NOT_EXIST", + "values": [ + "10.0.0.3/21" + ] + } + ], + "components": [ + "5e0ecf7d6f4f994eac9007b6" + ] } ] } diff --git a/test/playground/snapshot/dev.json b/test/playground/snapshot/dev.json index 1b66fab..a83c8ba 100644 --- a/test/playground/snapshot/dev.json +++ b/test/playground/snapshot/dev.json @@ -3,42 +3,9 @@ "domain": { "name": "currency-api", "description": "Currency API", + "version": 1588557288037, "activated": true, "group": [ - { - "name": "Rollout 2020", - "description": "Changes that will be applied during the rollout", - "activated": true, - "config": [ - { - "key": "FF2FOR2020", - "description": "Feature Flag", - "activated": true, - "strategies": [ - { - "strategy": "NETWORK_VALIDATION", - "activated": true, - "operation": "EXIST", - "values": [ - "10.0.0.3/24" - ] - }, - { - "strategy": "VALUE_VALIDATION", - "activated": true, - "operation": "NOT_EXIST", - "values": [ - "USA", - "Canada", - "Australia", - "Africa" - ] - } - ], - "components": [] - } - ] - }, { "name": "Rollout 2030", "description": "Changes that will be applied during the rollout", diff --git a/test/test.js b/test/test.js index 62779a6..5a3683b 100644 --- a/test/test.js +++ b/test/test.js @@ -4,7 +4,7 @@ const assert = require('chai').assert const sinon = require('sinon'); const Switcher = require('../src/index') const request = require('request-promise') -const services = require('../src/utils/services') +const services = require('../src/lib/services') const fs = require('fs'); // const { StrategiesType } = require('../src/utils/index') @@ -26,7 +26,7 @@ describe('E2E test - Switcher offline:', function () { fs.unwatchFile('./snapshot/default.json'); }) - it('Should be valid', async function () { + it('should be valid', async function () { await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) await switcher.isItOn('FF2FOR2020').then(function (result) { assert.isTrue(result) @@ -35,7 +35,7 @@ describe('E2E test - Switcher offline:', function () { }) }) - it('Should be valid - No prepare function called', async function () { + it('should be valid - No prepare function called', async function () { switcher.isItOn('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']).then(function (result) { assert.isTrue(result) }, function (error) { @@ -43,7 +43,7 @@ describe('E2E test - Switcher offline:', function () { }) }) - it('Should be valid - No prepare function called (no input as well)', function () { + it('should be valid - No prepare function called (no input as well)', function () { switcher.isItOn('FF2FOR2030').then(function (result) { assert.isTrue(result) }, function (error) { @@ -51,7 +51,7 @@ describe('E2E test - Switcher offline:', function () { }) }) - it('Should be invalid - Input (IP) does not match', async function () { + it('should be invalid - Input (IP) does not match', async function () { await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '192.168.0.2']) switcher.isItOn().then(function (result) { assert.isFalse(result) @@ -60,7 +60,7 @@ describe('E2E test - Switcher offline:', function () { }) }) - it('Should be valid assuming key to be false and then forgetting it', async function () { + it('should be valid assuming key to be false and then forgetting it', async function () { await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) assert.isTrue(await switcher.isItOn()) @@ -70,7 +70,7 @@ describe('E2E test - Switcher offline:', function () { assert.isTrue(await switcher.isItOn()) }) - it('Should be valid assuming unknown key to be true', async function () { + it('should be valid assuming unknown key to be true', async function () { await switcher.prepare('UNKNOWN', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) switcher.assume('UNKNOWN').true() @@ -84,7 +84,7 @@ describe('E2E test - Switcher offline:', function () { }) }) - it('Should be invalid - Offline file not found', async function () { + it('should be invalid - Offline file not found', async function () { try { new Switcher(url, apiKey, domain, component, environment, { offline: true, @@ -312,7 +312,7 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - let result = await switcher.isItOn('FF2FOR2030').then(function (result) { + await switcher.isItOn('FF2FOR2030').then(function (result) { assert.isUndefined(result) }, function (error) { assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) @@ -321,4 +321,102 @@ describe('Unit test - Switcher:', function () { }) +}) + +describe('E2E test - Switcher offline - Snapshot:', function () { + let switcher; + const apiKey = '$2b$08$S2Wj/wG/Rfs3ij0xFbtgveDtyUAjML1/TOOhocDg5dhOaU73CEXfK'; + const domain = 'currency-api'; + const component = 'Android'; + const environment = 'dev'; + const url = 'http://localhost:3000' + + const dataBuffer = fs.readFileSync('./snapshot/dev.json'); + const dataJSON = dataBuffer.toString(); + + let requestGetStub; + let requestPostStub; + let clientAuth; + let fsStub; + + afterEach(function() { + requestGetStub.restore(); + requestPostStub.restore(); + clientAuth.restore(); + fsStub.restore(); + }) + + beforeEach(function() { + switcher = new Switcher(url, apiKey, domain, component, environment, { + offline: true + }) + + clientAuth = sinon.stub(services, 'auth'); + requestGetStub = sinon.stub(request, 'get'); + requestPostStub = sinon.stub(request, 'post'); + fsStub = sinon.stub(fs, 'writeFileSync'); + }) + + this.afterAll(function() { + switcher.unloadSnapshot(); + }) + + it('should update snapshot', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: false })); // Snapshot outdated + requestPostStub.returns(Promise.resolve(JSON.stringify(JSON.parse(dataJSON), null, 4))); + // Mocking finishes + + assert.isTrue(await switcher.checkSnapshot()); + }) + + it('should NOT update snapshot', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: true })); // No available update + // Mocking finishes + + assert.isFalse(await switcher.checkSnapshot()); + }) + + it('should NOT update snapshot - check Snapshot Error', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.throws({ + error: { + errno: 'ECONNREFUSED', + code: 'ECONNREFUSED', + syscall: 'connect' + } + }); + // Mocking finishes + + await switcher.checkSnapshot().then(function (result) { + assert.isUndefined(result) + }, function (error) { + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) + }) + }) + + it('should NOT update snapshot - resolve Snapshot Error', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: false })); // Snapshot outdated + requestPostStub.throws({ + error: { + errno: 'ECONNREFUSED', + code: 'ECONNREFUSED', + syscall: 'connect' + } + }); + // Mocking finishes + + await switcher.checkSnapshot().then(function (result) { + assert.isUndefined(result) + }, function (error) { + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) + }) + }) + }) \ No newline at end of file From b57de521d83a8edb743f73c36a8f87fff9cad668 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Tue, 5 May 2020 23:35:30 -0700 Subject: [PATCH 04/15] Readme updated --- readme.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index 334ee6c..8d164a2 100644 --- a/readme.md +++ b/readme.md @@ -16,12 +16,13 @@ https://github.com/petruki/switcher-api Switcher Client is a friendly lib to interact with the Switcher API by: - Simplifying validations throughout your remote Switcher configuration. -- Able to work offline using a snapshot claimed from your remote Switcher-API. -- Able to run in silent mode that will prevent your application to not be 100% dependent on the online API. +- Able to work offline using a snapshot claimed from your configured remote Switcher-API. +- Able to run in silent mode that will prevent your application to not run 100% dependent on the online API. - Being flexible in order to remove the complexity of multi-staging (add as many environments as you want). - Being friendly by making possible to manipulate switchers without changing your online switchers. (useful for automated tests). - Being secure by using OAuth 2 flow. Requests are made using tokens that will validate your domain, component, environment and API key. -Tokens have an expiration time and are not stored. The Switcher Client is responsible to renew it using your settings. +- Keep your snapshot files updated by just calling one method and work with zero-latency for critical/sensitive applications. +Tokens have an expiration time and are not stored. The Switcher Client is responsible to renew it when necessary. # Usage 1) Configure your client @@ -96,4 +97,10 @@ switcher.isItOn('KEY') // it is going to be true Invoke forget to remove any switch assumption, like this: ```js switcher.forget('KEY') +``` + +## Snapshot version check +For working with zero-latency set this library to offline and keep your snapshot file updated by just calling checkSnapshot() +```js +switcher.checkSnapshot() ``` \ No newline at end of file From ccaef351dd713137ee3b70864349dbef2b16a945 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 11 May 2020 14:05:36 -0700 Subject: [PATCH 05/15] Updated snapshot fixture - resolved components from graphql: domain --- test/playground/snapshot/default.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/playground/snapshot/default.json b/test/playground/snapshot/default.json index f06cdcf..16e2156 100644 --- a/test/playground/snapshot/default.json +++ b/test/playground/snapshot/default.json @@ -3,14 +3,17 @@ "domain": { "name": "My Domain", "version": 1588557288037, + "description": "Technology solutions - Test 1\n\n$2b$08$CkraR0sT8JNcKPgLD6FJBuDf.JE4aUXqMloYFGFcl04sw8RcAwywm", "activated": true, "group": [ { "name": "Project 1", + "description": "Project #1 description", "activated": true, "config": [ { "key": "FEATURE01", + "description": "Feature #1 description", "activated": false, "strategies": [ { @@ -23,13 +26,14 @@ } ], "components": [ - "5e0ecf7d6f4f994eac9007b6", - "5e31372fe8533b5944d4e408", - "5e30f1898db9c44460d22770" + "Android", + "iOS", + "Windows" ] }, { "key": "FEATURE02", + "description": "Feature #2 description", "activated": true, "strategies": [ { @@ -47,10 +51,12 @@ }, { "name": "Project 2", + "description": "Project #2 description", "activated": true, "config": [ { "key": "FEATURE2020", + "description": "Feature #22020 description", "activated": true, "strategies": [ { @@ -63,7 +69,7 @@ } ], "components": [ - "5e0ecf7d6f4f994eac9007b6" + "Android" ] } ] From aef8559e82881e0ca4bc1ec5b041515787f36837 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:01:26 -0700 Subject: [PATCH 06/15] feat: Re-worked built-in mock feature. --- readme.md | 118 ++++++++++--------- src/index.d.ts | 43 +++---- src/index.js | 29 ++--- src/lib/bypasser.d.ts | 39 +++++++ src/lib/bypasser.js | 45 ++++--- src/lib/resolver.js | 2 +- src/lib/services.js | 64 +++++----- src/lib/snapshot.js | 10 +- src/utils/index.js | 54 ++++----- test/playground/index.js | 59 ++++++---- test/test.js | 246 +++++++++++++++++++-------------------- 11 files changed, 393 insertions(+), 316 deletions(-) create mode 100644 src/lib/bypasser.d.ts diff --git a/readme.md b/readme.md index 8d164a2..ee6a115 100644 --- a/readme.md +++ b/readme.md @@ -7,100 +7,112 @@ ![Switcher API: JavaScript Client: Cloud-based Feature Flag API](https://github.com/petruki/switcherapi-assets/blob/master/logo/switcherapi_js_client.png) -# Install -`npm install switcher-client` - # About -Module for working with Switcher-API. +Client JavaScript for working with Switcher-API. https://github.com/petruki/switcher-api -Switcher Client is a friendly lib to interact with the Switcher API by: -- Simplifying validations throughout your remote Switcher configuration. -- Able to work offline using a snapshot claimed from your configured remote Switcher-API. -- Able to run in silent mode that will prevent your application to not run 100% dependent on the online API. -- Being flexible in order to remove the complexity of multi-staging (add as many environments as you want). -- Being friendly by making possible to manipulate switchers without changing your online switchers. (useful for automated tests). -- Being secure by using OAuth 2 flow. Requests are made using tokens that will validate your domain, component, environment and API key. -- Keep your snapshot files updated by just calling one method and work with zero-latency for critical/sensitive applications. -Tokens have an expiration time and are not stored. The Switcher Client is responsible to renew it when necessary. +- Able to work offline using a snapshot file downloaded from your remote Switcher-API Domain. +- Silent mode automatically enables a contingent sub-process in case of connectivity issues. +- Built-in mock implementation for automated testing. +- Easy to setup. Switcher Context is responsible to manage all the complexity between your application and API. # Usage -1) Configure your client + +## Install +`npm install switcher-client` + +## Module initialization +The context properties stores all information regarding connectivity and strategy settings. + ```js const Switcher = require("switcher-client"); const apiKey = 'API Key'; const environment = 'default'; // Production = default -const domain = 'Your Domain Name'; -const component = 'Android'; -const url = 'http://localhost:3000/criteria'; +const domain = 'My Domain'; +const component = 'MyApp'; +const url = 'https://switcher-load-balance.herokuapp.com'; ``` -- **apiKey**: Obtained after creating your domain using the Switcher-API project. -- **environment**: You can run multiple environments. Production environment is 'default' which is created automatically after creating the domain. -- **domain**: This is your business name identification. -- **component**: This is the name of the application that will be using this API. + +- **apiKey**: Switcher-API key generated after creating a domain.. +- **environment**: Environment name. Production environment is named as 'default'. +- **domain**: Domain name. +- **component**: Application name. - **url**: Endpoint of your Swither-API. -2) Options - you can also activate features such as offline and silent mode +## Options +You can also activate features such as offline and silent mode: + ```js const offline = true; const snapshotLocation = './snapshot/'; const silentMode = true; const retryAfter = '5m'; + +let switcher = new Switcher(url, apiKey, domain, component, environment, { + offline, snapshotLocation, silentMode, retryAfter +}); ``` + - **offline**: If activated, the client will only fetch the configuration inside your snapshot file. The default value is 'false'. - **snapshotLocation**: Location of snapshot files. The default value is './snapshot/'. -- **silentMode**: If activated, all connections errors will be ignored and the client will automatically fetch the configuration into your snapshot. -- **retryAfter** : Set the duration you want the client to try to reach the online API again. (see moment documentation for time signature). The default value is 5m. +- **silentMode**: If activated, all connectivity issues will be ignored and the client will automatically fetch the configuration into your snapshot file. +- **retryAfter** : Time given to the module to re-establish connectivity with the API - e.g. 5s (s: seconds - m: minutes - h: hours). -3) Create the client -```js -const switcher = new Switcher(url, apiKey, domain, component, environment) -//or - using silent mode -const switcher = new Switcher(url, apiKey, domain, component, environment, { silentMode: true }) -//or - using offline mode -const switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true }) -``` -## Invoking switchers -**Scenario 1** +## Executing +There are a few different ways to call the API using the JavaScript module. +Here are some examples: + +1. **No parameters** +Invoking the API can be done by instantiating the switcher and calling *isItOn* passing its key as a parameter. -You want to setup the input of your switch before using it and call 'isItOn' some elsewhere. ```js -switcher.prepare('MY_KEY', [Switcher.StrategiesType.VALUE, 'USER_1') -switcher.isItOn() +const switcher = new Switcher(url, apiKey, domain, component, environment); +await switcher.isItOn('FEATURE01'); ``` -**Scenario 2** +2. **Promise** +Using promise is another way to call the API if you want, like: -You want to call isItOn without preparing, as simple as this: ```js -switcher.isItOn('KEY') +switcher.isItOnPromise('KEY') + .then(result => console.log('Result:', result)) + .catch(error => console.log(error)); ``` -**Scenario 3** +3. **Strategy validation - preparing input** +Loading information into the switcher can be made by using *prepare*, in case you want to include input from a different place of your code. Otherwise, it is also possible to include everything in the same call. -Using promise is another way to call the API if you want: ```js -switcher.isItOnPromise('KEY') - .then(result => console.log('Promise result:', result)) - .catch(error => console.log(error)); +switcher.prepare('FEATURE01', [Switcher.StrategiesType.VALUE, 'USER_1'); +switcher.isItOn(); ``` -## Bypassing switchers -You can also bypass your switcher configuration by invoking 'assume'. This is perfect for your test code where you want to test both scenarios when the switcher is true and false. +4. **Strategy validation - all-in-one execution** +All-in-one method is fast and include everything you need to execute a complex call to the API. + ```js -switcher.assume('KEY').true() -switcher.isItOn('KEY') // it is going to be true +await switcher.isItOn('FEATURE01', + [Switcher.StrategiesType.VALUE, 'User 1', + Switcher.StrategiesType.NETWORK, '192.168.0.1'] +); ``` -Invoke forget to remove any switch assumption, like this: +## Built-in mock feature +You can also bypass your switcher configuration by invoking 'Switcher.assume'. This is perfect for your test code where you want to test both scenarios when the switcher is true and false. + ```js -switcher.forget('KEY') +Switcher.assume('FEATURE01').true(); +switcher.isItOn('FEATURE01'); // true + +Switcher.forget('FEATURE01'); +switcher.isItOn('FEATURE01'); // Now, it's going to return the result retrieved from the API or the Snaopshot file ``` ## Snapshot version check -For working with zero-latency set this library to offline and keep your snapshot file updated by just calling checkSnapshot() +For convenience, an implementation of a domain version checker is available if you have external processes that manage snapshot files. + ```js -switcher.checkSnapshot() +switcher.checkSnapshot(); ``` \ No newline at end of file diff --git a/src/index.d.ts b/src/index.d.ts index 8054412..b56b192 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,3 +1,5 @@ +import Bypasser from "./lib/bypasser"; + declare class Switcher { constructor( @@ -16,7 +18,6 @@ declare class Switcher { key: string; input: string[]; exp: number; - bypassedKeys: Key; snapshot?: string; /** @@ -32,22 +33,21 @@ declare class Switcher { */ prepare(key: string, input?: string[]): void; - isItOn(key?: string, input?: string[]): boolean; - isItOnPromise(key?: string, input?: string[]): Promise; - /** - * Force a switcher value to return a given value by calling one of both methods - true() false() + * Execute async criteria * * @param key + * @param input */ - assume(key: string): Key; + isItOn(key?: string, input?: string[]): boolean; /** - * Remove forced value from a switcher + * Execute async criteria * * @param key + * @param input */ - forget(key: string): void; + isItOnPromise(key?: string, input?: string[]): Promise; /** * Read snapshot file locally and store in a parsed JSON object @@ -64,6 +64,20 @@ declare class Switcher { * Remove snapshot from real-time update */ unloadSnapshot(): void; + + /** + * Force a switcher value to return a given value by calling one of both methods - true() false() + * + * @param key + */ + static assume(key: string): Key; + + /** + * Remove forced value from a switcher + * + * @param key + */ + static forget(key: string): void; } declare interface SwitcherOptions { @@ -73,17 +87,4 @@ declare interface SwitcherOptions { retryAfter: string; } -declare class Key { - - constructor(key: string); - - key: string; - valaue: boolean; - - true(): void; - false(): void; - getKey(): string; - getValue(): boolean; -} - export = Switcher; \ No newline at end of file diff --git a/src/index.js b/src/index.js index eb1b41c..c989583 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ "use strict"; +const Bypasser = require('./lib/bypasser'); const { loadDomain, StrategiesType } = require('./utils/index'); -const { Key, searchBypassed } = require('./lib/bypasser'); const services = require('./lib/services'); const checkCriteriaOffline = require('./lib/resolver'); const validateSnapshot = require('./lib/snapshot'); @@ -47,7 +47,6 @@ class Switcher { } this.loadSnapshot(); - this.bypassedKeys = new Array(); } async prepare(key, input) { @@ -100,7 +99,7 @@ class Switcher { } async isItOn(key, input) { - const bypassKey = searchBypassed(this.key ? this.key : key, this.bypassedKeys); + const bypassKey = Bypasser.searchBypassed(this.key ? this.key : key); if (bypassKey) { return bypassKey.getValue(); } @@ -148,22 +147,6 @@ class Switcher { return new Promise((resolve) => resolve(this.isItOn(key, input))); } - assume(key) { - - const existentKey = searchBypassed(key, this.bypassedKeys); - if (existentKey) { - return existentKey; - } - - const keyBypassed = new Key(key); - this.bypassedKeys.push(keyBypassed) - return keyBypassed; - } - - forget(key) { - this.bypassedKeys.splice(this.bypassedKeys.indexOf(searchBypassed(key, this.bypassedKeys)), 1); - } - loadSnapshot() { if (this.snapshotLocation) { const snapshotFile = `${this.snapshotLocation}${this.environment}.json`; @@ -187,6 +170,14 @@ class Switcher { static get StrategiesType() { return StrategiesType; } + + static assume(key) { + return Bypasser.assume(key); + } + + static forget(key) { + return Bypasser.forget(key); + } } diff --git a/src/lib/bypasser.d.ts b/src/lib/bypasser.d.ts new file mode 100644 index 0000000..1083000 --- /dev/null +++ b/src/lib/bypasser.d.ts @@ -0,0 +1,39 @@ +declare class Bypasser { + + static bypassedKeys: Key; + + /** + * Force a switcher value to return a given value by calling one of both methods - true() false() + * + * @param key + */ + static assume(key: string): Key; + + /** + * Remove forced value from a switcher + * + * @param key + */ + static forget(key: string): void; + + /** + * Search for key registered via 'assume' + * + * @param key + */ + static searchBypassed(key: string): Key; +} + +declare class Key { + constructor(key: string); + + key: string; + valaue: boolean; + + true(): void; + false(): void; + getKey(): string; + getValue(): boolean; +} + +export = Bypasser; \ No newline at end of file diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index 55914ef..1bfe9d6 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -1,3 +1,33 @@ +class Bypasser { + static bypassedKeys = []; + + static assume(key) { + const existentKey = this.searchBypassed(key, Bypasser.bypassedKeys); + if (existentKey) { + return existentKey; + } + + const keyBypassed = new Key(key); + Bypasser.bypassedKeys.push(keyBypassed); + return keyBypassed; + } + + static forget(key) { + Bypasser.bypassedKeys.splice( + Bypasser.bypassedKeys.indexOf(this.searchBypassed(key, Bypasser.bypassedKeys)), 1); + } + + static searchBypassed(key) { + let existentKey; + Bypasser.bypassedKeys.forEach(async bk => { + if (bk.getKey() === key) { + return existentKey = bk; + } + }) + return existentKey; + } +} + class Key { constructor(key) { this.key = key; @@ -21,17 +51,4 @@ class Key { } } -function searchBypassed(key, bypassedKeys) { - let existentKey; - bypassedKeys.forEach(async bk => { - if (bk.getKey() === key) { - return existentKey = bk; - } - }) - return existentKey; -} - -module.exports = { - Key, - searchBypassed -} \ No newline at end of file +module.exports = Bypasser; \ No newline at end of file diff --git a/src/lib/resolver.js b/src/lib/resolver.js index 0e3575f..3793553 100644 --- a/src/lib/resolver.js +++ b/src/lib/resolver.js @@ -46,7 +46,7 @@ async function resolveCriteria(key, input, { } if (notFoundKey) { - throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`) + throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`); } return result; diff --git a/src/lib/services.js b/src/lib/services.js index 29a137b..27f2fad 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -3,11 +3,11 @@ const moment = require('moment'); exports.getEntry = (input) => { if (!input) { - return undefined + return undefined; } if (input.length % 2 !== 0) { - throw new Error(`Invalid input format for '${input}'`) + throw new Error(`Invalid input format for '${input}'`); } let entry = []; @@ -16,15 +16,15 @@ exports.getEntry = (input) => { entry.push({ strategy: input[i], input: input[i + 1] - }) + }); } - return entry + return entry; } exports.checkCriteria = async (url, token, key, input) => { try { - const entry = this.getEntry(input) + const entry = this.getEntry(input); const options = { url: `${url}/criteria`, qs: { @@ -34,22 +34,22 @@ exports.checkCriteria = async (url, token, key, input) => { 'Authorization': `Bearer ${token}` }, json: true - } + }; if (entry) { options.body = { entry - } + }; } const response = await request.post(options); return response.result; } catch (e) { - let error + let error; if (e.error) { - error = JSON.stringify(e.error) + error = JSON.stringify(e.error); } - throw new CriteriaError(e.error ? error : e.message) + throw new CriteriaError(e.error ? error : e.message); } } @@ -66,7 +66,7 @@ exports.auth = async (url, apiKey, domain, component, environment, options) => { component, environment } - } + }; return await request.post(postOptions); } catch (e) { @@ -76,15 +76,15 @@ exports.auth = async (url, apiKey, domain, component, environment, options) => { return { token: 'SILENT', exp: expirationTime.toDate().getTime() / 1000 - } + }; } } - let error + let error; if (e.error) { - error = JSON.stringify(e.error) + error = JSON.stringify(e.error); } - throw new AuthError(e.error ? error : e.message) + throw new AuthError(e.error ? error : e.message); } } @@ -96,16 +96,16 @@ exports.checkSnapshotVersion = async (url, token, version) => { 'Authorization': `Bearer ${token}` }, json: true - } + }; const response = await request.get(options); return response; } catch (e) { - let error + let error; if (e.error) { - error = JSON.stringify(e.error) + error = JSON.stringify(e.error); } - throw new SnapshotServiceError(e.error ? error : e.message) + throw new SnapshotServiceError(e.error ? error : e.message); } } @@ -133,39 +133,39 @@ exports.resolveSnapshot = async (url, token, domain, environment) => { query, variables: { domain, environment } }) - } + }; const response = await request.post(options); return JSON.stringify(JSON.parse(response), null, 4); } catch (e) { - let error + let error; if (e.error) { - error = JSON.stringify(e.error) + error = JSON.stringify(e.error); } - throw new SnapshotServiceError(e.error ? error : e.message) + throw new SnapshotServiceError(e.error ? error : e.message); } } class AuthError extends Error { constructor(message) { - super(`Something went wrong: ${message}`) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); } } class CriteriaError extends Error { constructor(message) { - super(`Something went wrong: ${message}`) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); } } class SnapshotServiceError extends Error { constructor(message) { - super(`Something went wrong: ${message}`) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); } } \ No newline at end of file diff --git a/src/lib/snapshot.js b/src/lib/snapshot.js index 5e8b629..5dee0a0 100644 --- a/src/lib/snapshot.js +++ b/src/lib/snapshot.js @@ -2,15 +2,15 @@ const { resolveSnapshot, checkSnapshotVersion } = require('./services'); const fs = require('fs'); async function validateSnapshot(url, token, domain, environment, snapshotLocation, snapshotVersion) { - const { status } = await checkSnapshotVersion(url, token, snapshotVersion) + const { status } = await checkSnapshotVersion(url, token, snapshotVersion); if (!status) { - const snapshot = await resolveSnapshot(url, token, domain, environment) + const snapshot = await resolveSnapshot(url, token, domain, environment); - fs.writeFileSync(`${snapshotLocation}${environment}.json`, snapshot) - return true + fs.writeFileSync(`${snapshotLocation}${environment}.json`, snapshot); + return true; } - return false + return false; } module.exports = validateSnapshot; \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js index c8ec206..889df05 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -4,11 +4,11 @@ const IPCIDR = require('ip-cidr'); const loadDomain = (snapshotLocation) => { try { - const dataBuffer = fs.readFileSync(snapshotLocation) - const dataJSON = dataBuffer.toString() - return JSON.parse(dataJSON) + const dataBuffer = fs.readFileSync(snapshotLocation); + const dataJSON = dataBuffer.toString(); + return JSON.parse(dataJSON); } catch (e) { - throw new Error(`Something went wrong: It was not possible to load the file at ${snapshotLocation}`) + throw new Error(`Something went wrong: It was not possible to load the file at ${snapshotLocation}`); } } @@ -32,19 +32,19 @@ const OperationsType = Object.freeze({ const processOperation = (strategy, operation, input, values) => { switch(strategy) { case StrategiesType.NETWORK: - return processNETWORK(operation, input, values) + return processNETWORK(operation, input, values); case StrategiesType.VALUE: - return processVALUE(operation, input, values) + return processVALUE(operation, input, values); case StrategiesType.TIME: - return processTime(operation, input, values) + return processTime(operation, input, values); case StrategiesType.DATE: - return processDate(operation, input, values) + return processDate(operation, input, values); } } function processNETWORK(operation, input, values) { - const cidrRegex = '^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$' + const cidrRegex = '^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$'; switch(operation) { case OperationsType.EXIST: @@ -52,10 +52,10 @@ function processNETWORK(operation, input, values) { if (values[i].match(cidrRegex)) { const cidr = new IPCIDR(values[i]); if (cidr.contains(input)) { - return true + return true; } } else { - return values.includes(input) + return values.includes(input); } } break; @@ -64,13 +64,13 @@ function processNETWORK(operation, input, values) { if (element.match(cidrRegex)) { const cidr = new IPCIDR(element); if (cidr.contains(input)) { - return true + return true; } } else { - return values.includes(input) + return values.includes(input); } }) - return result.length === 0 + return result.length === 0; } return false @@ -79,38 +79,38 @@ function processNETWORK(operation, input, values) { function processVALUE(operation, input, values) { switch(operation) { case OperationsType.EXIST: - return values.includes(input) + return values.includes(input); case OperationsType.NOT_EXIST: - return !values.includes(input) + return !values.includes(input); case OperationsType.EQUAL: - return input === values[0] + return input === values[0]; case OperationsType.NOT_EQUAL: - const result = values.filter(element => element === input) - return result.length === 0 + const result = values.filter(element => element === input); + return result.length === 0; } } function processTime(operation, input, values) { - const today = moment().format('YYYY-MM-DD') + const today = moment().format('YYYY-MM-DD'); switch(operation) { case OperationsType.LOWER: - return moment(`${today}T${input}`).isSameOrBefore(`${today}T${values[0]}`) + return moment(`${today}T${input}`).isSameOrBefore(`${today}T${values[0]}`); case OperationsType.GREATER: - return moment(`${today}T${input}`).isSameOrAfter(`${today}T${values[0]}`) + return moment(`${today}T${input}`).isSameOrAfter(`${today}T${values[0]}`); case OperationsType.BETWEEN: - return moment(`${today}T${input}`).isBetween(`${today}T${values[0]}`, `${today}T${values[1]}`) + return moment(`${today}T${input}`).isBetween(`${today}T${values[0]}`, `${today}T${values[1]}`); } } function processDate(operation, input, values) { switch(operation) { case OperationsType.LOWER: - return moment(input).isSameOrBefore(values[0]) + return moment(input).isSameOrBefore(values[0]); case OperationsType.GREATER: - return moment(input).isSameOrAfter(values[0]) + return moment(input).isSameOrAfter(values[0]); case OperationsType.BETWEEN: - return moment(input).isBetween(values[0], values[1]) + return moment(input).isBetween(values[0], values[1]); } } @@ -119,4 +119,4 @@ module.exports = { processOperation, StrategiesType, OperationsType -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/playground/index.js b/test/playground/index.js index 0eeb36a..eeebfc1 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -1,8 +1,8 @@ -const Switcher = require('../../src/index') +const Switcher = require('../../src/index'); -let switcher +let switcher; -function setupSwitcher() { +function setupSwitcher(offline) { const apiKey = '$2b$08$7U/KJBVgG.FQtYEKKnbLe.o6p7vBrfHFRgMipZTaokSmVFiduXq/y' // const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S' const domain = 'My Domain' @@ -11,41 +11,58 @@ function setupSwitcher() { const url = 'http://localhost:3000' switcher = new Switcher(url, apiKey, domain, component, environment, { - offline: true - }) + offline + }); } // Requires online API const testSnapshotUpdate = async () => { - setupSwitcher() + setupSwitcher(false); - let result = await switcher.isItOn('FEATURE2020') - console.log(result) + let result = await switcher.isItOn('FEATURE2020'); + console.log(result); - await switcher.checkSnapshot() - await new Promise(resolve => setTimeout(resolve, 1000)) + await switcher.checkSnapshot(); + await new Promise(resolve => setTimeout(resolve, 1000)); - result = await switcher.isItOn('FEATURE2020') - console.log(result) + result = await switcher.isItOn('FEATURE2020'); + console.log(result); - switcher.unloadSnapshot() + switcher.unloadSnapshot(); } const testAsyncCall = async () => { - setupSwitcher(); + setupSwitcher(true); let result = await switcher.isItOn('FEATURE2020'); - console.log(result) + console.log(result); switcher.isItOnPromise('FEATURE2020') .then(result => console.log('Promise result:', result)) - .catch(error => console.log(error)) + .catch(error => console.log(error)); + + switcher.assume('FEATURE2020').false(); + result = await switcher.isItOn('FEATURE2020'); + console.log('Value changed:', result); + + switcher.unloadSnapshot(); +} + +const testBypasser = async () => { + setupSwitcher(true); + + let result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + Switcher.assume('FEATURE2020').false(); + result = await switcher.isItOn('FEATURE2020'); + console.log(result); - switcher.assume('FEATURE2020').false() - result = await switcher.isItOn('FEATURE2020') - console.log('Value changed:', result) + Switcher.forget('FEATURE2020'); + result = await switcher.isItOn('FEATURE2020'); + console.log(result); - switcher.unloadSnapshot() + switcher.unloadSnapshot(); } -testAsyncCall() \ No newline at end of file +testBypasser(); \ No newline at end of file diff --git a/test/test.js b/test/test.js index 5a3683b..e17d460 100644 --- a/test/test.js +++ b/test/test.js @@ -14,87 +14,87 @@ describe('E2E test - Switcher offline:', function () { const domain = 'currency-api'; const component = 'Android'; const environment = 'default'; - const url = 'http://localhost:3000' + const url = 'http://localhost:3000'; this.beforeAll(function() { switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true - }) - }) + }); + }); this.afterAll(function() { fs.unwatchFile('./snapshot/default.json'); - }) + }); it('should be valid', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); await switcher.isItOn('FF2FOR2020').then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); it('should be valid - No prepare function called', async function () { switcher.isItOn('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']).then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); it('should be valid - No prepare function called (no input as well)', function () { switcher.isItOn('FF2FOR2030').then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); it('should be invalid - Input (IP) does not match', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '192.168.0.2']) + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '192.168.0.2']); switcher.isItOn().then(function (result) { - assert.isFalse(result) + assert.isFalse(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); it('should be valid assuming key to be false and then forgetting it', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); - assert.isTrue(await switcher.isItOn()) - switcher.assume('FF2FOR2020').false() - assert.isFalse(await switcher.isItOn()) - switcher.forget('FF2FOR2020') - assert.isTrue(await switcher.isItOn()) + assert.isTrue(await switcher.isItOn()); + Switcher.assume('FF2FOR2020').false(); + assert.isFalse(await switcher.isItOn()); + Switcher.forget('FF2FOR2020'); + assert.isTrue(await switcher.isItOn()); }) it('should be valid assuming unknown key to be true', async function () { - await switcher.prepare('UNKNOWN', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + await switcher.prepare('UNKNOWN', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); - switcher.assume('UNKNOWN').true() - assert.isTrue(await switcher.isItOn()) + Switcher.assume('UNKNOWN').true(); + assert.isTrue(await switcher.isItOn()); - switcher.forget('UNKNOWN') + Switcher.forget('UNKNOWN'); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"error":"Unable to load a key UNKNOWN"}', error.message) - }) - }) + assert.equal('Something went wrong: {"error":"Unable to load a key UNKNOWN"}', error.message); + }); + }); it('should be invalid - Offline file not found', async function () { try { new Switcher(url, apiKey, domain, component, environment, { offline: true, snapshotLocation: 'somewhere/' - }) + }); } catch (error) { - assert.equal('Something went wrong: It was not possible to load the file at somewhere/default.json', error.message) + assert.equal('Something went wrong: It was not possible to load the file at somewhere/default.json', error.message); } - }) -}) + }); +}); describe('Unit test - Switcher:', function () { @@ -123,21 +123,21 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); - await switcher.prepare('FLAG_1', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) - assert.isTrue(await switcher.isItOn()) - }) + await switcher.prepare('FLAG_1', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); + assert.isTrue(await switcher.isItOn()); + }); it('should renew the token after expiration', async function () { this.timeout(3000); requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+1000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - const spyPrepare = sinon.spy(switcher, 'prepare') + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + const spyPrepare = sinon.spy(switcher, 'prepare'); // Prepare the call generating the token - await switcher.prepare('MY_FLAG') - await switcher.isItOn() + await switcher.prepare('MY_FLAG'); + await switcher.isItOn(); // The program delay 2 secs later for the next call await new Promise(resolve => setTimeout(resolve, 2000)); @@ -146,80 +146,80 @@ describe('Unit test - Switcher:', function () { clientAuth.returns(Promise.resolve({ token: 'asdad12d2232d2323f', exp: (Date.now()+1000)/1000 })); // In this time period the expiration time has reached, it should call prepare once again to renew the token - await switcher.isItOn() - assert.equal(spyPrepare.callCount, 2) + await switcher.isItOn(); + assert.equal(spyPrepare.callCount, 2); // In the meantime another call is made by the time the token is still not expired, so there is no need to call prepare again - await switcher.isItOn() - assert.equal(spyPrepare.callCount, 2) - }) + await switcher.isItOn(); + assert.equal(spyPrepare.callCount, 2); + }); it('should be valid - when sending key without calling prepare', async function () { requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - assert.isTrue(await switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])) - }) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + assert.isTrue(await switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])); + }); it('should be valid - when preparing key and sending input strategy afterwards', async function () { requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare('MY_FLAG') - assert.isTrue(await switcher.isItOn(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])) - }) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare('MY_FLAG'); + assert.isTrue(await switcher.isItOn(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])); + }); it('should be invalid - Missing url field', async function () { - let switcher = new Switcher(undefined, 'apiKey', 'domain', 'component', 'default') + let switcher = new Switcher(undefined, 'apiKey', 'domain', 'component', 'default'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing url field', error.message) - }) - }) + assert.equal('Something went wrong: Missing url field', error.message); + }); + }); it('should be invalid - Missing API Key field', async function () { - let switcher = new Switcher('url', undefined, 'domain', 'component', 'default') + let switcher = new Switcher('url', undefined, 'domain', 'component', 'default'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing API Key field', error.message) - }) - }) + assert.equal('Something went wrong: Missing API Key field', error.message); + }); + }); it('should be invalid - Missing key field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing key field', error.message) - }) - }) + assert.equal('Something went wrong: Missing key field', error.message); + }); + }); it('should be invalid - Missing component field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', undefined, 'default') + let switcher = new Switcher('url', 'apiKey', 'domain', undefined, 'default'); switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']).then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing component field', error.message) - }) - }) + assert.equal('Something went wrong: Missing component field', error.message); + }); + }); it('should be invalid - Missing token field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); @@ -227,24 +227,24 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']).then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing token field', error.message) - }) - }) + assert.equal('Something went wrong: Missing token field', error.message); + }); + }); it('should be invalid - bad strategy input', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare('MY_WRONG_FLAG', ['THIS IS WRONG']) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare('MY_WRONG_FLAG', ['THIS IS WRONG']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Invalid input format for \'THIS IS WRONG\'', error.message) - }) - }) + assert.equal('Something went wrong: Invalid input format for \'THIS IS WRONG\'', error.message); + }); + }); it('should run in silent mode', async function () { requestStub.restore(); @@ -263,28 +263,28 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default', { silentMode: true, retryAfter: '1s' - }) - const spyPrepare = sinon.spy(switcher, 'prepare') + }); + const spyPrepare = sinon.spy(switcher, 'prepare'); // First attempt to reach the online API - Since it's configured to use silent mode, it should return true (according to the snapshot) - let result = await switcher.isItOn('FF2FOR2030') - assert.equal(result, true) + let result = await switcher.isItOn('FF2FOR2030'); + assert.equal(result, true); await new Promise(resolve => setTimeout(resolve, 500)); // The call below is in silent mode. It is getting the configuration from the offline snapshot again - result = await switcher.isItOn() - assert.equal(result, true) + result = await switcher.isItOn(); + assert.equal(result, true); // As the silent mode was configured to retry after 3 seconds, it's still in time, // therefore, it's not required to try to reach the online API yet. - assert.equal(spyPrepare.callCount, 1) + assert.equal(spyPrepare.callCount, 1); await new Promise(resolve => setTimeout(resolve, 1000)); // Silent mode has expired its time. Again, the online API is still offline. Prepare is called once again. - result = await switcher.isItOn() - assert.equal(result, true) - assert.equal(spyPrepare.callCount, 2) + result = await switcher.isItOn(); + assert.equal(result, true); + assert.equal(spyPrepare.callCount, 2); await new Promise(resolve => setTimeout(resolve, 1500)); @@ -293,9 +293,9 @@ describe('Unit test - Switcher:', function () { clientAuth = sinon.stub(services, 'auth'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - result = await switcher.isItOn() - assert.equal(result, false) - }) + result = await switcher.isItOn(); + assert.equal(result, false); + }); it('should throw error if not in silent mode', async function () { requestStub.restore(); @@ -310,18 +310,18 @@ describe('Unit test - Switcher:', function () { } }); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); await switcher.isItOn('FF2FOR2030').then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) - }) - }) + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); - }) + }); -}) +}); describe('E2E test - Switcher offline - Snapshot:', function () { let switcher; @@ -329,7 +329,7 @@ describe('E2E test - Switcher offline - Snapshot:', function () { const domain = 'currency-api'; const component = 'Android'; const environment = 'dev'; - const url = 'http://localhost:3000' + const url = 'http://localhost:3000'; const dataBuffer = fs.readFileSync('./snapshot/dev.json'); const dataJSON = dataBuffer.toString(); @@ -349,17 +349,17 @@ describe('E2E test - Switcher offline - Snapshot:', function () { beforeEach(function() { switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true - }) + }); clientAuth = sinon.stub(services, 'auth'); requestGetStub = sinon.stub(request, 'get'); requestPostStub = sinon.stub(request, 'post'); fsStub = sinon.stub(fs, 'writeFileSync'); - }) + }); this.afterAll(function() { switcher.unloadSnapshot(); - }) + }); it('should update snapshot', async function () { // Mocking starts @@ -369,7 +369,7 @@ describe('E2E test - Switcher offline - Snapshot:', function () { // Mocking finishes assert.isTrue(await switcher.checkSnapshot()); - }) + }); it('should NOT update snapshot', async function () { // Mocking starts @@ -378,7 +378,7 @@ describe('E2E test - Switcher offline - Snapshot:', function () { // Mocking finishes assert.isFalse(await switcher.checkSnapshot()); - }) + }); it('should NOT update snapshot - check Snapshot Error', async function () { // Mocking starts @@ -393,11 +393,11 @@ describe('E2E test - Switcher offline - Snapshot:', function () { // Mocking finishes await switcher.checkSnapshot().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) - }) - }) + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); it('should NOT update snapshot - resolve Snapshot Error', async function () { // Mocking starts @@ -413,10 +413,10 @@ describe('E2E test - Switcher offline - Snapshot:', function () { // Mocking finishes await switcher.checkSnapshot().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) - }) - }) + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); -}) \ No newline at end of file +}); \ No newline at end of file From 6c64d489844fcab0300dda7419fa941513eecd9d Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:10:28 -0700 Subject: [PATCH 07/15] fix: added typescript to the build stage since there are some classes definition using typescript --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 0884795..b2f5f6a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ jobs: - stage: compile script: - npm install + - npm install -g typescript - stage: test script: - npm test From ec6dbee2cdb9e7b4f1f414c761cff0b17ca18275 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:21:05 -0700 Subject: [PATCH 08/15] fix: added typescript to dev dependencies --- .travis.yml | 1 - package.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b2f5f6a..0884795 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,6 @@ jobs: - stage: compile script: - npm install - - npm install -g typescript - stage: test script: - npm test diff --git a/package.json b/package.json index 20ef696..ffe3d58 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "mocha": "^6.2.2", "mocha-sonarqube-reporter": "^1.0.1", "nyc": "^14.1.1", - "sinon": "^7.5.0" + "sinon": "^7.5.0", + "typescript": "^3.9.5" }, "repository": { "type": "git", From 19e3476b6c88eed805136bd0f1b535f5afc9e0dd Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:32:03 -0700 Subject: [PATCH 09/15] fix: Ignored ts files from Sonar analysis --- package.json | 3 +-- sonar-project.properties | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ffe3d58..20ef696 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "mocha": "^6.2.2", "mocha-sonarqube-reporter": "^1.0.1", "nyc": "^14.1.1", - "sinon": "^7.5.0", - "typescript": "^3.9.5" + "sinon": "^7.5.0" }, "repository": { "type": "git", diff --git a/sonar-project.properties b/sonar-project.properties index 79f5d52..ddaa540 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,6 +10,7 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sources=src sonar.tests=test sonar.language=js +sonar.exclusions=src/**/*.d.ts sonar.dynamicAnalysis=reuseReports # Encoding of the source code. Default is default system encoding From 7f39bb2dd9afb070f7d8a944094fc218f8ebc16e Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:45:24 -0700 Subject: [PATCH 10/15] fix: Updated array instance on Bypasser --- src/lib/bypasser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index 1bfe9d6..79f2d70 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -1,5 +1,5 @@ class Bypasser { - static bypassedKeys = []; + static bypassedKeys = new Array(); static assume(key) { const existentKey = this.searchBypassed(key, Bypasser.bypassedKeys); From 7f3b70d616aeb13e9a68b6709401c26fdbcd25ad Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:51:36 -0700 Subject: [PATCH 11/15] fix: Attempting to fix bypasser.js --- src/lib/bypasser.js | 48 +++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index 79f2d70..7111111 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -1,3 +1,28 @@ +"use strict"; + +class Key { + constructor(key) { + this.key = key; + this.value = undefined; + } + + true() { + this.value = true; + } + + false() { + this.value = false; + } + + getKey() { + return this.key; + } + + getValue() { + return this.value; + } +} + class Bypasser { static bypassedKeys = new Array(); @@ -28,27 +53,4 @@ class Bypasser { } } -class Key { - constructor(key) { - this.key = key; - this.value = undefined; - } - - true() { - this.value = true; - } - - false() { - this.value = false; - } - - getKey() { - return this.key; - } - - getValue() { - return this.value; - } -} - module.exports = Bypasser; \ No newline at end of file From 9681e86435c2536f483391f26304dbdf967b2dca Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 13:04:28 -0700 Subject: [PATCH 12/15] fix: Splitted Bypassere and Key classes --- src/lib/bypasser.d.ts | 14 ++------------ src/lib/bypasser.js | 23 +---------------------- src/lib/key.d.ts | 13 +++++++++++++ src/lib/key.js | 26 ++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 src/lib/key.d.ts create mode 100644 src/lib/key.js diff --git a/src/lib/bypasser.d.ts b/src/lib/bypasser.d.ts index 1083000..fe49aa0 100644 --- a/src/lib/bypasser.d.ts +++ b/src/lib/bypasser.d.ts @@ -1,3 +1,5 @@ +import Key from "./key"; + declare class Bypasser { static bypassedKeys: Key; @@ -24,16 +26,4 @@ declare class Bypasser { static searchBypassed(key: string): Key; } -declare class Key { - constructor(key: string); - - key: string; - valaue: boolean; - - true(): void; - false(): void; - getKey(): string; - getValue(): boolean; -} - export = Bypasser; \ No newline at end of file diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index 7111111..c5ba64a 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -1,27 +1,6 @@ "use strict"; -class Key { - constructor(key) { - this.key = key; - this.value = undefined; - } - - true() { - this.value = true; - } - - false() { - this.value = false; - } - - getKey() { - return this.key; - } - - getValue() { - return this.value; - } -} +const Key = require('./key'); class Bypasser { static bypassedKeys = new Array(); diff --git a/src/lib/key.d.ts b/src/lib/key.d.ts new file mode 100644 index 0000000..12e1a9d --- /dev/null +++ b/src/lib/key.d.ts @@ -0,0 +1,13 @@ +declare class Key { + constructor(key: string); + + key: string; + valaue: boolean; + + true(): void; + false(): void; + getKey(): string; + getValue(): boolean; +} + +export = Key; \ No newline at end of file diff --git a/src/lib/key.js b/src/lib/key.js new file mode 100644 index 0000000..6bac299 --- /dev/null +++ b/src/lib/key.js @@ -0,0 +1,26 @@ +"use strict"; + +class Key { + constructor(key) { + this.key = key; + this.value = undefined; + } + + true() { + this.value = true; + } + + false() { + this.value = false; + } + + getKey() { + return this.key; + } + + getValue() { + return this.value; + } +} + +module.exports = Key; \ No newline at end of file From 8347898ab7c8e134264c6dfc9dfc3c0143483074 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 13:14:28 -0700 Subject: [PATCH 13/15] fix: Attempting to fix bypasser.js --- src/index.d.ts | 2 +- src/lib/bypasser.js | 2 ++ test/test.js | 8 +++----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index b56b192..8b7bb38 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,4 @@ -import Bypasser from "./lib/bypasser"; +import Key from "./lib/key"; declare class Switcher { diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index c5ba64a..feab7a5 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -5,6 +5,8 @@ const Key = require('./key'); class Bypasser { static bypassedKeys = new Array(); + constructor() {} + static assume(key) { const existentKey = this.searchBypassed(key, Bypasser.bypassedKeys); if (existentKey) { diff --git a/test/test.js b/test/test.js index e17d460..777beb0 100644 --- a/test/test.js +++ b/test/test.js @@ -1,10 +1,8 @@ -"use strict" - const assert = require('chai').assert const sinon = require('sinon'); -const Switcher = require('../src/index') -const request = require('request-promise') -const services = require('../src/lib/services') +const Switcher = require('../src/index'); +const request = require('request-promise'); +const services = require('../src/lib/services'); const fs = require('fs'); // const { StrategiesType } = require('../src/utils/index') From 9a4af02cdac70fae1809a54d6d72ffe13c9f856b Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 13:24:27 -0700 Subject: [PATCH 14/15] fix: Updated node from 8 to 12 --- .travis.yml | 2 +- package.json | 2 +- src/lib/bypasser.js | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0884795..04eebcf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ sudo: false language: node_js node_js: -- '8' +- '12' addons: sonarcloud: diff --git a/package.json b/package.json index 20ef696..a06c173 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,6 @@ "url": "https://github.com/petruki/switcher-client-master" }, "engines": { - "node": ">= 8.0.0" + "node": "^12.0.0" } } diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js index feab7a5..c4f50fd 100644 --- a/src/lib/bypasser.js +++ b/src/lib/bypasser.js @@ -5,8 +5,6 @@ const Key = require('./key'); class Bypasser { static bypassedKeys = new Array(); - constructor() {} - static assume(key) { const existentKey = this.searchBypassed(key, Bypasser.bypassedKeys); if (existentKey) { @@ -29,7 +27,7 @@ class Bypasser { if (bk.getKey() === key) { return existentKey = bk; } - }) + }); return existentKey; } } From e349d6f75d8cf84e028609b25968dbd41254efa2 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 14 Jun 2020 16:22:53 -0700 Subject: [PATCH 15/15] doc: Edited readme.md [skip ci] --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index ee6a115..0021c57 100644 --- a/readme.md +++ b/readme.md @@ -38,7 +38,7 @@ const url = 'https://switcher-load-balance.herokuapp.com'; - **environment**: Environment name. Production environment is named as 'default'. - **domain**: Domain name. - **component**: Application name. -- **url**: Endpoint of your Swither-API. +- **url**: Swither-API endpoint. ## Options You can also activate features such as offline and silent mode: