diff --git a/README.md b/README.md index 3c4d4d0..843f1df 100644 --- a/README.md +++ b/README.md @@ -63,20 +63,22 @@ const local = true; const logger = true; const snapshotLocation = './snapshot/'; const snapshotAutoUpdateInterval = 3; +const snapshotWatcher = true; const silentMode = '5m'; const certPath = './certs/ca.pem'; Client.buildContext({ url, apiKey, domain, component, environment }, { - local, logger, snapshotLocation, snapshotAutoUpdateInterval, silentMode, certPath + local, logger, snapshotLocation, snapshotAutoUpdateInterval, snapshotWatcher, silentMode, certPath }); -let switcher = Client.getSwitcher(); +const switcher = Client.getSwitcher(); ``` - **local**: If activated, the client will only fetch the configuration inside your snapshot file. The default value is 'false' - **logger**: If activated, it is possible to retrieve the last results from a given Switcher key using Client.getLogger('KEY') - **snapshotLocation**: Location of snapshot files. The default value is './snapshot/' - **snapshotAutoUpdateInterval**: Enable Snapshot Auto Update given an interval in seconds (default: 0 disabled). +- **snapshotWatcher**: Enable Snapshot Watcher to monitor changes in the snapshot file (default: false). - **silentMode**: Enable contigency given the time for the client to retry - e.g. 5s (s: seconds - m: minutes - h: hours) - **regexSafe**: Enable REGEX Safe mode - Prevent agaist reDOS attack (default: true). - **regexMaxBlackList**: Number of entries cached when REGEX Strategy fails to perform (reDOS safe) - default: 50 @@ -212,6 +214,16 @@ Client.watchSnapshot({ }); ``` +Alternatively, you can also use the client context configuration to monitor changes in the snapshot file.
+ +```js +Client.buildContext({ domain, component, environment }, { + local: true, + snapshotLocation: './snapshot/', + snapshotWatcher: true +}); +``` + ## Snapshot version check For convenience, an implementation of a domain version checker is available if you have external processes that manage snapshot files. diff --git a/package.json b/package.json index 4fc29a6..6db7a86 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ ], "devDependencies": { "@babel/eslint-parser": "^7.27.5", - "@typescript-eslint/eslint-plugin": "^8.34.1", - "@typescript-eslint/parser": "^8.34.1", + "@typescript-eslint/eslint-plugin": "^8.35.0", + "@typescript-eslint/parser": "^8.35.0", "c8": "^10.1.3", "chai": "^5.2.0", "env-cmd": "^10.1.0", diff --git a/src/client.d.ts b/src/client.d.ts index 267f573..572ec11 100644 --- a/src/client.d.ts +++ b/src/client.d.ts @@ -137,7 +137,7 @@ export type Criteria = { * * @param strategy (StrategiesType) value to be evaluated */ - and(strategy: string, input: string | string[]): this; + and(strategy: string, input: string | string[]): Criteria; } @@ -156,10 +156,29 @@ export type LoggerRecord = { * SwitcherContext is required to build the context to communicate with the API. */ export type SwitcherContext = { + /** + * The API URL, e.g. https://api.switcherapi.com + */ url?: string; + + /** + * The API key provided for the component + */ apiKey?: string; + + /** + * The domain name of the Switcher API account + */ domain: string; + + /** + * The component name registered in the Switcher API account + */ component?: string; + + /** + * The environment name registered in the Switcher API account + */ environment?: string; } @@ -167,14 +186,63 @@ export type SwitcherContext = { * SwitcherOptions is optional to build the context to communicate with the API. */ export type SwitcherOptions = { + /** + * When enabled it will use the local snapshot (file or in-memory) + * If not set, it will use the remote API + */ local?: boolean; + + /** + * When enabled it allows inspecting the result details with Client.getLogger(key) + * If not set, it will not log the result details + */ logger?: boolean; + + /** + * The location of the snapshot file + * If not set, it will use the in-memory snapshot + */ snapshotLocation?: string; + + /** + * The interval in milliseconds to auto-update the snapshot + * If not set, it will not auto-update the snapshot + */ snapshotAutoUpdateInterval?: number; + + /** + * When enabled it will watch the snapshot file for changes + */ + snapshotWatcher?: boolean; + + /** + * When defined it will switch to local during the specified time before it switches back to remote + * e.g. 5s (s: seconds - m: minutes - h: hours) + */ silentMode?: string; + + /** + * When enabled it will check Regex strategy using background workers + * If not set, it will check Regex strategy synchronously + */ regexSafe?: boolean; + + /** + * The regex max black list + * If not set, it will use the default value + */ regexMaxBlackList?: number; + + /** + * The regex max time limit in milliseconds + * If not set, it will use the default value + */ regexMaxTimeLimit?: number; + + /** + * The certificate path for secure connections + * If not set, it will use the default certificate + */ certPath?: string; } @@ -185,7 +253,14 @@ export type SwitcherOptions = { * @param fetchRemote when true, it will initialize the snapshot from the API */ export type LoadSnapshotOptions = { + /** + * When enabled it will watch the snapshot file for changes + */ watchSnapshot?: boolean; + + /** + * When enabled it will fetch the remote API + */ fetchRemote?: boolean; } diff --git a/src/client.js b/src/client.js index a330890..ded4077 100644 --- a/src/client.js +++ b/src/client.js @@ -51,18 +51,25 @@ export class Client { static #buildOptions(options) { remote.removeAgent(); - if (SWITCHER_OPTIONS.CERT_PATH in options && options.certPath) { - remote.setCerts(options.certPath); - } - - if (SWITCHER_OPTIONS.SILENT_MODE in options && options.silentMode) { - this.#initSilentMode(options.silentMode); - } + + const optionsHandler = { + [SWITCHER_OPTIONS.CERT_PATH]: (val) => val && remote.setCerts(val), + [SWITCHER_OPTIONS.SILENT_MODE]: (val) => val && this.#initSilentMode(val), + [SWITCHER_OPTIONS.SNAPSHOT_AUTO_UPDATE_INTERVAL]: (val) => { + GlobalOptions.updateOptions({ snapshotAutoUpdateInterval: val }); + this.scheduleSnapshotAutoUpdate(); + }, + [SWITCHER_OPTIONS.SNAPSHOT_WATCHER]: (val) => { + GlobalOptions.updateOptions({ snapshotWatcher: val }); + this.watchSnapshot(); + } + }; - if (SWITCHER_OPTIONS.SNAPSHOT_AUTO_UPDATE_INTERVAL in options) { - GlobalOptions.updateOptions({ snapshotAutoUpdateInterval: options.snapshotAutoUpdateInterval }); - this.scheduleSnapshotAutoUpdate(); - } + Object.entries(optionsHandler).forEach(([key, handler]) => { + if (key in options) { + handler(options[key]); + } + }); this.#initTimedMatch(options); } diff --git a/src/lib/constants.js b/src/lib/constants.js index 63cc6c4..6f3151f 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -8,6 +8,7 @@ export const DEFAULT_REGEX_MAX_TIME_LIMIT = 3000; export const SWITCHER_OPTIONS = Object.freeze({ SNAPSHOT_LOCATION: 'snapshotLocation', SNAPSHOT_AUTO_UPDATE_INTERVAL: 'snapshotAutoUpdateInterval', + SNAPSHOT_WATCHER: 'snapshotWatcher', SILENT_MODE: 'silentMode', REGEX_SAFE: 'regexSafe', REGEX_MAX_BLACK_LIST: 'regexMaxBlackList', diff --git a/test/helper/utils.js b/test/helper/utils.js index 11ae4c7..b0113e6 100644 --- a/test/helper/utils.js +++ b/test/helper/utils.js @@ -54,6 +54,21 @@ export async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +export async function getSwitcherResulUntil(switcher, key, expectToBe, retries = 20) { + let result; + + for (let i = 0; i < retries; i++) { + await sleep(500); + result = await switcher.isItOn(key); + + if (result === expectToBe) { + break; + } + } + + return result; +}; + export function deleteGeneratedSnapshot(dirname) { if (!fs.existsSync(dirname)) { return; diff --git a/test/playground/index.js b/test/playground/index.js index cead18e..98ddfa2 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -107,6 +107,7 @@ const _testAsyncCall = async () => { Client.unloadSnapshot(); }; +// Does not require remote API const _testBypasser = async () => { setupSwitcher(true); const switcher = Client.getSwitcher(); @@ -140,6 +141,29 @@ const _testWatchSnapshot = async () => { }); }; +// Does not require remote API +const _testWatchSnapshotContextOptions = async () => { + Client.buildContext({ domain, environment }, { + snapshotLocation, + snapshotWatcher: true, + local: true, + logger: true + }); + + await Client.loadSnapshot(); + + const switcher = Client.getSwitcher(); + + setInterval(async () => { + const time = Date.now(); + const result = await switcher + .detail() + .isItOn(SWITCHER_KEY); + + console.log(`- ${Date.now() - time} ms - ${JSON.stringify(result)}`); + }, 1000); +}; + // Requires remote API const _testSnapshotAutoUpdate = async () => { Client.buildContext({ url, apiKey, domain, component, environment }, diff --git a/test/switcher-watch-snapshot.test.js b/test/switcher-watch-snapshot.test.js index 09d1f9c..c96a4a7 100644 --- a/test/switcher-watch-snapshot.test.js +++ b/test/switcher-watch-snapshot.test.js @@ -2,14 +2,58 @@ import { assert } from 'chai'; import { writeFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; import { Client } from '../switcher-client.js'; -import { deleteGeneratedSnapshot } from './helper/utils.js'; +import { deleteGeneratedSnapshot, getSwitcherResulUntil } from './helper/utils.js'; + +const domain = 'Business'; +const component = 'business-service'; +let devJSON; + +const updateSwitcher = (environment, status) => { + const copyOfDevJSON = JSON.parse(JSON.stringify(devJSON)); + copyOfDevJSON.data.domain.group[0].config[0].activated = status; + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(copyOfDevJSON, null, 4)); +}; + +const invalidateJSON = (environment) => { + writeFileSync(`generated-watch-snapshots/${environment}.json`, '[INVALID]'); +}; + +const beforeAll = () => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + + const dataBuffer = readFileSync('./test/snapshot/dev.json'); + devJSON = JSON.parse(dataBuffer.toString()); + devJSON.data.domain.group[0].config[0].activated = true; +}; + +const afterAll = () => { + for (let i = 1; i <= 4; i++) { + const filePath = `generated-watch-snapshots/watch${i}.json`; + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } + + Client.unloadSnapshot(); + setTimeout(() => deleteGeneratedSnapshot('./generated-watch-snapshots'), 0); +}; + +const afterEach = () => { + Client.unloadSnapshot(); +}; -describe('E2E test - Switcher local - Watch Snapshot:', function () { - const domain = 'Business'; - const component = 'business-service'; - let devJSON; +describe('E2E test - Switcher local - Watch Snapshot (watchSnapshot):', function () { + this.beforeAll(beforeAll); + this.afterAll(afterAll); + this.afterEach(afterEach); const initContext = async (environment) => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(devJSON, null, 4)); Client.buildContext({ domain, component, environment }, { @@ -21,44 +65,6 @@ describe('E2E test - Switcher local - Watch Snapshot:', function () { await Client.loadSnapshot(); }; - const updateSwitcher = (environment, status) => { - const copyOfDevJSON = JSON.parse(JSON.stringify(devJSON)); - copyOfDevJSON.data.domain.group[0].config[0].activated = status; - writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(copyOfDevJSON, null, 4)); - }; - - const invalidateJSON = (environment) => { - writeFileSync(`generated-watch-snapshots/${environment}.json`, '[INVALID]'); - }; - - this.beforeAll(function() { - if (!existsSync('generated-watch-snapshots/')) { - mkdirSync('generated-watch-snapshots/', { recursive: true }); - } - - const dataBuffer = readFileSync('./test/snapshot/dev.json'); - devJSON = JSON.parse(dataBuffer.toString()); - devJSON.data.domain.group[0].config[0].activated = true; - }); - - this.afterEach(function() { - Client.unloadSnapshot(); - }); - - this.afterAll(function() { - if (existsSync('generated-watch-snapshots/watch1.json')) - unlinkSync('generated-watch-snapshots/watch1.json'); - - if (existsSync('generated-watch-snapshots/watch2.json')) - unlinkSync('generated-watch-snapshots/watch2.json'); - - if (existsSync('generated-watch-snapshots/watch3.json')) - unlinkSync('generated-watch-snapshots/watch3.json'); - - Client.unloadSnapshot(); - deleteGeneratedSnapshot('./generated-watch-snapshots'); - }); - it('should read from snapshot - without watching', function (done) { this.timeout(10000); @@ -129,4 +135,39 @@ describe('E2E test - Switcher local - Watch Snapshot:', function () { Client.testMode(false); }); +}); + +describe('E2E test - Switcher local - Watch Snapshot (context):', function () { + this.beforeAll(beforeAll); + this.afterAll(afterAll); + this.afterEach(afterEach); + + const initContext = async (environment) => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(devJSON, null, 4)); + + Client.buildContext({ domain, component, environment }, { + snapshotLocation: 'generated-watch-snapshots/', + snapshotWatcher: true, + local: true, + regexSafe: false + }); + + await Client.loadSnapshot(); + }; + + it('should read from updated snapshot', async function () { + this.timeout(10000); + + await initContext('watch4'); + + const switcher = Client.getSwitcher(); + assert.isTrue(await switcher.isItOn('FF2FOR2030')); + updateSwitcher('watch4', false); + + assert.isFalse(await getSwitcherResulUntil(switcher, 'FF2FOR2030', false)); + }); }); \ No newline at end of file