From 2efd0ca9bd2c5e1645fc6f150e9151fc5646f292 Mon Sep 17 00:00:00 2001 From: Brandon Date: Mon, 12 Jun 2023 22:09:06 +0200 Subject: [PATCH] feat: allow listen for changes made on parameters and bag objects --- README.md | 17 ++++- package-lock.json | 24 ++++++ package.json | 2 + src/__tests__/bag.test.ts | 112 +++++++++++++++++++++++++++ src/__tests__/exports.test.ts | 2 +- src/index.ts | 2 +- src/klient.ts | 2 +- src/services/bag.ts | 48 ------------ src/services/bag/bag.ts | 59 ++++++++++++++ src/services/bag/watch.ts | 140 ++++++++++++++++++++++++++++++++++ src/toolbox/object.ts | 36 +++++++++ 11 files changed, 391 insertions(+), 53 deletions(-) create mode 100644 src/__tests__/bag.test.ts delete mode 100644 src/services/bag.ts create mode 100644 src/services/bag/bag.ts create mode 100644 src/services/bag/watch.ts create mode 100644 src/toolbox/object.ts diff --git a/README.md b/README.md index da4e0a2..6f0a514 100644 --- a/README.md +++ b/README.md @@ -598,13 +598,13 @@ klient.parameters.get('request.headers'); // -// Set a new parameter +// Set a custom parameter // klient.parameters.set('env', 'dev'); // -// Overwrite a parameter dynamically +// Overwrite an existant parameter // klient.parameters.set('request.headers', { 'Content-Type': 'application/json', @@ -612,6 +612,19 @@ klient.parameters.set('request.headers', { }); +// +// Listen for changes by watching specific property +// Will execute callback when target property value has been changed +// +klient.parameters.watch( + 'request.headers', // target path + (next, prev) => { // Runnable callback + // ... + }, + false // Execute if a nested prop changed (deep option) +); + + // // Recursively merge existant parameters with new ones // diff --git a/package-lock.json b/package-lock.json index acbd535..d336a97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "axios": "^0.27.0", + "deep-diff": "^1.0.2", "deepmerge": "^3.0.0", "object-path": "^0.11.8" }, @@ -19,6 +20,7 @@ "@klient/open-stack-cli": "latest", "@klient/testing": "~1.1.0", "@release-it/conventional-changelog": "~5.1.1", + "@types/deep-diff": "^1.0.2", "@types/jest": "~27.4.1", "@types/node": "~17.0.43", "@types/object-path": "~0.11.1", @@ -2214,6 +2216,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-WD2O611C7Oz7RSwKbSls8LaznKfWfXh39CHY9Amd8FhQz+NJRe20nUHhYpOopVq9M2oqDZd4L6AzqJIXQycxiA==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -4347,6 +4355,11 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==" + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -16098,6 +16111,12 @@ "@babel/types": "^7.3.0" } }, + "@types/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-WD2O611C7Oz7RSwKbSls8LaznKfWfXh39CHY9Amd8FhQz+NJRe20nUHhYpOopVq9M2oqDZd4L6AzqJIXQycxiA==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -17662,6 +17681,11 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, + "deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==" + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", diff --git a/package.json b/package.json index eeb8c3e..ecdaf26 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "axios": "^0.27.0", + "deep-diff": "^1.0.2", "deepmerge": "^3.0.0", "object-path": "^0.11.8" }, @@ -56,6 +57,7 @@ "@klient/open-stack-cli": "latest", "@klient/testing": "~1.1.0", "@release-it/conventional-changelog": "~5.1.1", + "@types/deep-diff": "^1.0.2", "@types/jest": "~27.4.1", "@types/node": "~17.0.43", "@types/object-path": "~0.11.1", diff --git a/src/__tests__/bag.test.ts b/src/__tests__/bag.test.ts new file mode 100644 index 0000000..cdcc9cf --- /dev/null +++ b/src/__tests__/bag.test.ts @@ -0,0 +1,112 @@ +import Klient from '..'; + +test('watch', () => { + const unclonable = new Klient(); + const { parameters } = new Klient({ + request: { + headers: { + 'Content-Type': 'application/json' + } + }, + arr: [{ prop: true }, unclonable] + }); + + const innerParams = parameters as any; + const clonedParams = parameters.all() as any; + + expect(JSON.stringify(Object.keys(clonedParams))).toBe( + JSON.stringify(['url', 'extensions', 'request', 'debug', 'arr']) + ); + expect(clonedParams.arr === innerParams).toBeFalsy(); + expect(clonedParams.arr[0] === innerParams.arr[0]).toBeFalsy(); + expect(clonedParams.arr[1] === innerParams.arr[1]).toBeTruthy(); + + const urlWatchSpy = jest.fn(); + const requestWatchSpy = jest.fn(); + + parameters.watch('url', urlWatchSpy); + // Check duplication + parameters.watch('url', urlWatchSpy); + parameters.watch('request.headers.Content-Type', requestWatchSpy); + + parameters.set('url', undefined); + parameters.set('url', 'http://localhost'); + + expect(urlWatchSpy).toBeCalledTimes(1); + expect(urlWatchSpy).toBeCalledWith('http://localhost', undefined); + + parameters.set('request.headers', {}); + + expect(requestWatchSpy).toBeCalledWith(undefined, 'application/json'); + + parameters.merge({ + request: { + headers: { + 'Content-Type': 'application/xml' + } + } + }); + + expect(requestWatchSpy).toBeCalledWith('application/xml', undefined); +}); + +test('unwatch', () => { + const { parameters } = new Klient(); + + const testWatchSpy = jest.fn(); + + parameters.unwatch('test', () => undefined); + parameters.watch('test', testWatchSpy); + parameters.unwatch('test', testWatchSpy); + + parameters.set('test', true); + + expect(testWatchSpy).not.toBeCalled(); +}); + +test('watch:deep', () => { + const { parameters } = new Klient({ + example: { + nested: { + test: true + } + } + }); + + const exampleDeepSpy = jest.fn(); + const exampleSpy = jest.fn(); + const exampleNestedSpy = jest.fn(); + const exampleNestedDeepSpy = jest.fn(); + const exampleNestedTestSpy = jest.fn(); + + parameters.watch('example', exampleSpy); + parameters.watch('example', exampleDeepSpy, true); + parameters.watch('example.nested', exampleNestedSpy); + parameters.watch('example.nested', exampleNestedDeepSpy, true); + parameters.watch('example.nested.test', exampleNestedTestSpy); + + expect(parameters.watchers['example'].length).toBe(2); + expect(parameters.watchers['example.nested'].length).toBe(2); + expect(parameters.watchers['example.nested.test'].length).toBe(1); + + parameters.set('example.nested.test', false); + + expect(exampleSpy).not.toBeCalled(); + expect(exampleNestedSpy).not.toBeCalled(); + + expect(exampleDeepSpy).toBeCalledWith( + { + nested: { + test: false + } + }, + { + nested: { + test: true + } + } + ); + + expect(exampleNestedDeepSpy).toBeCalledWith({ test: false }, { test: true }); + expect(exampleNestedTestSpy).toBeCalledWith(false, true); +}); diff --git a/src/__tests__/exports.test.ts b/src/__tests__/exports.test.ts index de21e20..08e8497 100644 --- a/src/__tests__/exports.test.ts +++ b/src/__tests__/exports.test.ts @@ -1,5 +1,5 @@ import SourceExtensions from '../extensions'; -import SourceBag from '../services/bag'; +import SourceBag from '../services/bag/bag'; import SourceDispatcher from '../services/dispatcher/dispatcher'; import SourceRequestFactory from '../services/request/factory'; import SourceRequest from '../services/request/request'; diff --git a/src/index.ts b/src/index.ts index 118a3a6..88fe1fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import Klient from './klient'; export { AxiosError } from 'axios'; export { default as Extensions } from './extensions'; -export { default as Bag } from './services/bag'; +export { default as Bag } from './services/bag/bag'; export { default as Dispatcher } from './services/dispatcher/dispatcher'; export { default as RequestFactory } from './services/request/factory'; export { default as Request } from './services/request/request'; diff --git a/src/klient.ts b/src/klient.ts index 870bddb..5726f31 100644 --- a/src/klient.ts +++ b/src/klient.ts @@ -1,4 +1,4 @@ -import Bag from './services/bag'; +import Bag from './services/bag/bag'; import Dispatcher from './services/dispatcher/dispatcher'; import RequestFactory from './services/request/factory'; import Extensions from './extensions'; diff --git a/src/services/bag.ts b/src/services/bag.ts deleted file mode 100644 index d55e692..0000000 --- a/src/services/bag.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as deepmerge from 'deepmerge'; -import * as objectPath from 'object-path'; - -type BagItems = Record; - -export default class Bag implements BagItems { - [x: string]: unknown; - - constructor(items: BagItems = {}) { - Object.keys(items).forEach((key) => { - this.set(key, items[key]); - }); - } - - get(path: string) { - return objectPath.get(this, path); - } - - set(path: string, value: unknown): this { - objectPath.set(this, path, value); - return this; - } - - merge(...items: BagItems[]): this { - const nextState = deepmerge.all([this, ...items], { - // Replacing array with next value - arrayMerge: (_destinationArray: unknown[], sourceArray: unknown[]) => sourceArray, - // Merge only array & plain object - isMergeableObject: (o: object) => o?.constructor === Array || o?.constructor === Object - }); - - Object.keys(nextState).forEach((key) => { - this.set(key, nextState[key]); - }); - - return this; - } - - all() { - const all: BagItems = {}; - - Object.keys(this).forEach((key) => { - all[key] = this[key]; - }); - - return all; - } -} diff --git a/src/services/bag/bag.ts b/src/services/bag/bag.ts new file mode 100644 index 0000000..8d4cd20 --- /dev/null +++ b/src/services/bag/bag.ts @@ -0,0 +1,59 @@ +import * as deepmerge from 'deepmerge'; +import * as objectPath from 'object-path'; + +import { isPlainObject, isPlainArray, softClone } from '../../toolbox/object'; +import { watch, unwatch, invokeWatchers, getWatchers, Watchable, WatchCallback } from './watch'; + +type BagItems = Record; + +export default class Bag implements BagItems, Watchable { + [x: string]: unknown; + + constructor(items: BagItems = {}) { + Object.keys(items).forEach((key) => { + this[key] = items[key]; + }); + } + + get watchers() { + return getWatchers(this); + } + + get(path: string) { + return objectPath.get(this.all(), path); + } + + all() { + return softClone(this); + } + + set(path: string, value: unknown): this { + const prevState = this.all(); + + objectPath.set(this, path, value); + + return invokeWatchers(this, this.all(), prevState); + } + + merge(...items: BagItems[]): this { + const prevState = this.all(); + const nextState = deepmerge.all([this.all(), ...items], { + arrayMerge: (_destinationArray: unknown[], sourceArray: unknown[]) => sourceArray, + isMergeableObject: (o: object) => isPlainArray(o) || isPlainObject(o) + }); + + Object.keys(nextState).forEach((key) => { + this[key] = nextState[key]; + }); + + return invokeWatchers(this, nextState, prevState); + } + + watch(path: string, onChange: WatchCallback, deep = false): this { + return watch(this, path, onChange, deep); + } + + unwatch(path: string, onChange: WatchCallback): this { + return unwatch(this, path, onChange); + } +} diff --git a/src/services/bag/watch.ts b/src/services/bag/watch.ts new file mode 100644 index 0000000..c40c6f9 --- /dev/null +++ b/src/services/bag/watch.ts @@ -0,0 +1,140 @@ +import * as objectPath from 'object-path'; +import * as deepDiff from 'deep-diff'; + +export type Watchable = object; +export type WatchCallback = (next: T, prev: Z) => void; + +type WatcherItem = { callback: WatchCallback; deep: boolean }; + +/** + * Each watchable object is stored with a generated ID + */ +const instances: Record = {}; + +/** + * All watchers attached to watchable objects are stored as described below : + * + * watchers = { + * 'watchableObjectID: { // Storage is accessible with Instance ID + * 'path.to.property': [ // Watchers are grouped by target path + * { callback, deep }, + * ] + * } + * } + */ +const watchers: Record> = {}; + +/** + * Determine the instance ID for given watchable object. + */ +function getInstanceId(wachtable: Watchable) { + const ids = Object.keys(instances); + let id = null; + + for (let i = 0, len = ids.length; i < len; i += 1) { + if (instances[ids[i]] === wachtable) { + id = ids[i]; + break; + } + } + + if (id === null) { + id = Math.random().toString(36).substring(2); + instances[id] = wachtable; + } + + return id; +} + +/** + * Extract watchers collection, grouped by path, for given watchable object + */ +export function getWatchers(wachtable: Watchable) { + const id = getInstanceId(wachtable); + + if (!watchers[id]) { + watchers[id] = {}; + } + + return watchers[id]; +} + +/** + * Register callback to listen changes made on specific path of given watchable object + */ +export function watch(watchable: T, path: string, onChange: WatchCallback, deep: boolean): T { + const id = getInstanceId(watchable); + + watchers[id] = getWatchers(watchable); + + const collection = watchers[id][path] || []; + + for (let i = 0, len = collection.length; i < len; i += 1) { + if (collection[i].callback === onChange) { + return watchable; + } + } + + collection.push({ callback: onChange, deep }); + watchers[id][path] = collection; + + return watchable; +} + +/** + * Unregister watcher callback for given path + */ +export function unwatch(watchable: T, path: string, onChange: WatchCallback): T { + const id = getInstanceId(watchable); + + watchers[id] = getWatchers(watchable); + + const collection = watchers[id][path] || []; + let index: number | null = null; + + for (let i = 0, len = collection.length; i < len; i += 1) { + if (collection[i].callback === onChange) { + index = i; + break; + } + } + + if (index === null) { + return watchable; + } + + collection.splice(index, 1); + watchers[id][path] = collection; + + return watchable; +} + +/** + * Invoke all watchers attached to given watchable object with prev and next state + */ +export function invokeWatchers(watchable: T, next: object, prev: object): T { + const watched = getWatchers(watchable); + const watchedPaths = Object.keys(watched); + + if (watchedPaths.length === 0) { + return watchable; + } + + const changedPaths = deepDiff(prev, next)?.map((diff) => (diff.path as string[]).join('.')); + const invoked: WatcherItem[] = []; + + changedPaths?.forEach((path) => { + watchedPaths.forEach((targetPath) => { + if (!path.startsWith(targetPath)) return; + + watched[targetPath].forEach((watcher) => { + if ((path === targetPath || watcher.deep) && !invoked.includes(watcher)) { + watcher.callback(objectPath.get(next, targetPath), objectPath.get(prev, targetPath)); + invoked.push(watcher); + } + }); + }); + }); + + return watchable; +} diff --git a/src/toolbox/object.ts b/src/toolbox/object.ts new file mode 100644 index 0000000..4d9d139 --- /dev/null +++ b/src/toolbox/object.ts @@ -0,0 +1,36 @@ +export type AnyObject = Record; + +/** + * Check if a value is a native JS object + */ +export function isPlainObject(value: unknown) { + return value !== null && typeof value === 'object' && value.constructor.name === 'Object'; +} + +/** + * Check if a value is a native JS array + */ +export function isPlainArray(value: unknown) { + return Array.isArray(value) && value.constructor.name === 'Array'; +} + +/** + * Deep clone object properties (traverse only native plain objects) + */ +export function softClone(obj: AnyObject) { + const values: AnyObject = {}; + + Object.keys(obj).forEach((prop) => { + const value = obj[prop]; + + if (isPlainObject(value)) { + values[prop] = softClone(value as AnyObject); + } else if (isPlainArray(value)) { + values[prop] = (value as []).map((b) => (isPlainObject(b) ? softClone(b) : b)); + } else { + values[prop] = value; + } + }); + + return values; +}