diff --git a/.gitignore b/.gitignore index 85e0f5342d915ec6f16ea2bb9e8232fdbc42ca78..858bb7f2bc152c735c0a214c151fbd4119aa138d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules **/*.crt **/*.key +**/*.log src/testy.ts +src/testy.log +testy.log web node qjs diff --git a/package.json b/package.json index 8fb3c32f343920b1d8794cf6410b07d4a3fbc46c..5172a2e16521b854dad87aa27ec5d0b9d7256d69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "usp-js", - "version": "0.3.2", + "version": "0.3.3", "description": "Helper library for easy usp communication using mqtt over tcp or ws.", "main": "node/index.js", "browser": "web/index.js", diff --git a/src/commands/1.0,1.1/get.ts b/src/commands/1.0,1.1/get.ts index c64ecf32c1848c6b785f819f7bd5c8869f93bf89..5bd05bed2c191924bc8d5c564b8d976999fbfc5f 100644 --- a/src/commands/1.0,1.1/get.ts +++ b/src/commands/1.0,1.1/get.ts @@ -2,40 +2,11 @@ import { DecodeFn, EncodeFn } from "../../types"; import * as util from "../util"; const decode: DecodeFn = (msg, decodeOptions) => { - const resolvedPathResultsArr = util.searchAll(msg, "resolvedPathResults"); - if (decodeOptions?.raw) return [resolvedPathResultsArr]; - - const requestedPath = util.search(msg, "requestedPath"); - if (resolvedPathResultsArr) { - // path has search query (ex. Device.IP.Interface.[Name=="wan"].) - const pathIncludesSearch = - typeof requestedPath === "string" && - (requestedPath.includes("*") || requestedPath.includes("[")); - - const pathSplit = requestedPath.split("."); - - // found multiple items - const hasMultipleIndexes = util.hasMultipleIndexes( - resolvedPathResultsArr - .map((it) => - Object.keys(it.resultParams).map((p) => it.resolvedPath + p) - ) - .flat(1), - pathSplit - ); - - const shouldBeArray = - pathIncludesSearch || - hasMultipleIndexes || - resolvedPathResultsArr.length > 1; - + const reqPathResults = util.search(msg, "reqPathResults"); + if (decodeOptions?.raw) return [reqPathResults]; + if (reqPathResults) { return [ - util.processGetResult( - resolvedPathResultsArr, - shouldBeArray, - requestedPath, - decodeOptions - ), + util.processGetResult(reqPathResults as util.PathResult[], decodeOptions), ]; } return [null]; diff --git a/src/commands/1.2/get.ts b/src/commands/1.2/get.ts index 13caa65acfe9b294df967c177be34920c7dbe940..71e73225a179dd8a68df9ac872fc7dc616e03c29 100644 --- a/src/commands/1.2/get.ts +++ b/src/commands/1.2/get.ts @@ -2,32 +2,11 @@ import { DecodeFn, EncodeFn } from "../../types"; import * as util from "../util"; const decode: DecodeFn = (msg, decodeOptions) => { - const resolvedPathResultsArr = util.searchAll(msg, "resolvedPathResults"); - if (decodeOptions?.raw) return [resolvedPathResultsArr]; - - const requestedPaths = util.searchAll(msg, "requestedPath"); - const resolvedPaths = util.searchAll(msg, "resolvedPath"); - if (resolvedPathResultsArr) { - // path has search query (ex. Device.IP.Interface.[Name=="wan"].) - const pathIncludesSearch = requestedPaths.some( - (path) => path.includes("*") || path.includes("[") - ); - const hasMultipleRequestedPaths = requestedPaths.length > 1; - const resultIsArray = requestedPaths.some((reqPath) => - resolvedPaths.some((path) => - util.isDigit(path.replace(reqPath, "").replace(".", "")) - ) - ); - - const shouldBeArray = - pathIncludesSearch || hasMultipleRequestedPaths || resultIsArray; + const reqPathResults = util.search(msg, "reqPathResults"); + if (decodeOptions?.raw) return [reqPathResults]; + if (reqPathResults) { return [ - util.processGetResult( - resolvedPathResultsArr, - shouldBeArray, - requestedPaths[0], - decodeOptions, - ), + util.processGetResult(reqPathResults as util.PathResult[], decodeOptions), ]; } return [null]; diff --git a/src/commands/util.ts b/src/commands/util.ts index 2f643dfd3ec647a2253082e9ed52059685699aef..38918157f41c9cbb59ae458e65f8dad504a1f697 100644 --- a/src/commands/util.ts +++ b/src/commands/util.ts @@ -1,4 +1,10 @@ -import { CommandType, DecodeOptions } from "../types"; +import { + CommandType, + DecodeOptions, + GetNestedReturn, + UspProperty, + UspPropertyList, +} from "../types"; // import set from "lodash.set"; const digitDotRe = /^\d+\..*$/; @@ -72,7 +78,7 @@ const fixPath = (s: string): string => .split(".[") .join("["); -export const convertToNestedObject = (arr: any[], retainPath?: boolean) => { +export const convertToNestedObject = (arr: any[]) => { const res = {}; arr .map((it) => @@ -133,9 +139,6 @@ export const unwrapObject = (data: any): any => export const unwrapArray = (arr: any) => Array.isArray(arr) && arr.length === 1 ? arr[0] : arr; -const isObject = (val: any): boolean => - typeof val === "object" && val !== null && !Array.isArray(val); - export const isEmptyObject = (obj: any): boolean => obj !== null && obj !== undefined && @@ -143,31 +146,7 @@ export const isEmptyObject = (obj: any): boolean => typeof obj === "object" && Object.keys(obj).length === 0; -export const isEmpty = (v: any): boolean => - v === undefined || v === null || isEmptyObject(v); - -const clearObj = (obj) => - isObject(obj) && Object.keys(obj).length === 1 ? Object.values(obj)[0] : obj; - -const clearArray = (arr: Array<any>) => - arr.length === 1 && Array.isArray(arr[0]) ? arr[0] : arr; - -export const fullyUnwrapObject = (obj: any, shouldBeArray: boolean) => { - if (isObject(obj) && Object.keys(obj).length === 1) - return fullyUnwrapObject(Object.values(obj)[0], shouldBeArray); - - const isArray = Array.isArray(obj); - if (shouldBeArray) - if (isArray) - return clearArray(obj.filter((v) => !isEmptyObject(v)).map(clearObj)); - else return isEmptyObject(obj) ? [] : [clearObj(obj)]; - else if (isArray) - return fullyUnwrapObject( - clearObj(obj.find((v) => !isEmpty(v))), - shouldBeArray - ); - else return obj; -}; +export const isEmpty = (v: any): boolean => isEmptyObject(v); export function makeBuffer( rootRecord: any, @@ -206,55 +185,155 @@ export const parseID = (msg: any) => { return id; }; -const addPaths = (arr: any[]) => - arr.map(({ resolvedPath, resultParams }) => ({ - resolvedPath, - resultParams: { ...resultParams, query: resolvedPath }, - })); - -type RetainPathGetResult = { - query: string; - result: string | Record<string, string> | RetainPathGetResult[]; -}; - -const getOther = (obj: Record<string, string>, skipKey: string) => - obj[Object.keys(obj).find((key) => key !== skipKey) || ""]; +const grab = (item: any, key: string) => + Array.isArray(item) + ? item.find((val) => val !== null && val !== undefined) + : item[key]; +const skipKeys = (obj: any, keys: string[]) => + keys.length === 1 + ? grab(obj, keys[0]) + : skipKeys(grab(obj, keys[0]), keys.slice(1)); -const skipKey = (obj: Record<string, string>, keyToSkip: string) => - Object.entries(obj).reduce( - (acc, [key, val]) => (key !== keyToSkip ? { ...acc, [key]: val } : acc), - {} - ); +const removeNulls = (obj: any) => + typeof obj !== "object" + ? obj + : Array.isArray(obj) + ? obj.filter((v) => v !== null).map(removeNulls) + : Object.entries(obj).reduce( + (acc, [key, value]) => ({ + [key]: Array.isArray(value) + ? value.filter((v) => v !== null).map(removeNulls) + : removeNulls(value), + ...acc, + }), + {} + ); -const isValue = (obj: Record<string, string>) => Object.keys(obj).length === 2; +const pathJoin = (a: string, b: string) => + a.endsWith(".") ? a + b : a + "." + b; -const nestResult = ( - result: any, - query?: string -): RetainPathGetResult => ({ - query: query || result.query, - result: Array.isArray(result) - ? result.map((v) => nestResult(v)) - : isValue(result) - ? getOther(result, "query") - : skipKey(result, "query"), -}); +const addQueries = (obj: any, path: string) => + typeof obj !== "object" + ? obj + : Array.isArray(obj) + ? obj + .map((v, i) => + v === null + ? null + : addQueries(v, pathJoin(path, (i + 1).toString()) + ".") + ) + .filter((v) => v !== null) + : { + __query__: path, + ...Object.entries(obj).reduce( + (acc, [key, val]) => ({ + ...acc, + [key]: + typeof val !== "object" + ? val + : addQueries(val, pathJoin(path, key) + "."), + }), + {} + ), + }; export const processGetResult = ( - resolvedPathResultsArr: any[], - shouldBeArray: boolean, - requestedPath: string, - decodeOptions?: DecodeOptions, + reqPathResults: PathResult[], + decodeOptions?: DecodeOptions ) => { const retainPath = decodeOptions?.retainPath === true; - const resolvedValues = retainPath - ? addPaths(resolvedPathResultsArr) - : resolvedPathResultsArr; - const unflattened = convertToNestedObject(resolvedValues); - const unwrapped = fullyUnwrapObject(unflattened, shouldBeArray); - const result = isEmptyObject(unwrapped) ? [] : unwrapped; - const modifiedResult = retainPath - ? nestResult(result, requestedPath) - : result; - return modifiedResult; + const results = reqPathResults.map((resolvedResult) => { + const respType = determineResponseType(resolvedResult); + if (resolvedResult.resolvedPathResults === undefined) return null; + + const results = resolvedResult.resolvedPathResults || []; + if (respType === ResponseType.Property) { + const result = Object.values(results[0].resultParams)[0]; + return !retainPath + ? result + : ({ __query__: resolvedResult.requestedPath, result } as UspProperty); + } + if (respType === ResponseType.PropertyList) { + const result = results.map( + (result) => Object.values(result.resultParams)[0] + ); + return !retainPath + ? result + : ({ + __query__: resolvedResult.requestedPath, + result: result.map((str, i) => ({ + __query__: + results[i].resolvedPath + + Object.keys(results[i].resultParams)[0], + result: str, + })), + } as UspPropertyList); + } + if ( + respType === ResponseType.Object || + respType === ResponseType.ObjectList + ) { + const mainObject = skipKeys( + convertToNestedObject(results), + resolvedResult.requestedPath.split(".").slice(0, -1) + ); + const cleanedObject = removeNulls(mainObject); + return !retainPath + ? cleanedObject + : { + __query__: resolvedResult.requestedPath, + result: addQueries(mainObject, resolvedResult.requestedPath), + }; + } + + return null; + }); + + return results.length === 1 ? results[0] : results; +}; + +export type PathResult = { + requestedPath: string; + resolvedPathResults?: { + resolvedPath: string; + resultParams: { + key: string; + }; + }[]; +}; + +export enum ResponseType { + Property = "Property", + PropertyList = "PropertyList", + Object = "Object", + ObjectList = "ObjectList", +} + +const containsQuery = (path: string): boolean => + path.split(".").some((part) => part.includes("*") || part.includes("[")); +const endsWithProperty = (path: string): boolean => + /^.+\.[A-Za-z]+$/.test(path); +const endsWithIndex = (path: string): boolean => /^.+\.\d+\.$/.test(path); +const endsWithIndexedProperty = (path: string): boolean => + /^.+\.\d+\..+$/.test(path.split(".").slice(-2)[0]); +const containsIndexes = (pathResult: PathResult): boolean => + pathResult.resolvedPathResults === undefined + ? false + : pathResult.resolvedPathResults.some( + ({ resolvedPath, resultParams }) => + (endsWithIndex(resolvedPath) && + resolvedPath.split(".").length === + pathResult.requestedPath.split(".").length + 1) || + Object.keys(resultParams).some((key) => + endsWithIndexedProperty(pathResult.requestedPath + key) + ) + ); + +const determineResponseType = (pathResult: PathResult): ResponseType => { + const isQuery = containsQuery(pathResult.requestedPath); + if (endsWithProperty(pathResult.requestedPath)) + return isQuery ? ResponseType.PropertyList : ResponseType.Property; + if (isQuery || containsIndexes(pathResult)) return ResponseType.ObjectList; + if (endsWithIndex(pathResult.requestedPath)) return ResponseType.Object; + return ResponseType.Object; }; diff --git a/src/configurations/build.ts b/src/configurations/build.ts index 382b435cf3ab1182f12341948175c9abd73ce557..6d1e90453570f85f86459aef57b383b0a24b5aa4 100644 --- a/src/configurations/build.ts +++ b/src/configurations/build.ts @@ -49,14 +49,24 @@ const timeoutWrapper = (promise: () => Promise<any>, ms?: number) => { } else return promise(); }; -const applyCommandChanges = <T extends (...args: any[]) => any>(opts: Options, cmdName: keyof USP, cmd: T, args: any[]) => { +const applyCommandChanges = <T extends (...args: any[]) => any>( + opts: Options, + cmdName: keyof USP, + cmd: T, + args: any[] +) => { if (opts.get?.retainPath === true && cmdName === "get") { - const newOptions = args.length > 1 ? "retainPath" in args[1] ? args[1] : { retainPath: true, ...args[1] } : {}; - return cmd(...[args[0], newOptions, ...args.slice(2)]) + const newOptions = + args.length > 1 + ? "retainPath" in args[1] + ? args[1] + : { retainPath: true, ...args[1] } + : {}; + return cmd(...[args[0], newOptions, ...args.slice(2)]); } - + return cmd(...args); -} +}; const wrap = <T extends (...args: any[]) => any>( opts: Options, @@ -65,7 +75,8 @@ const wrap = <T extends (...args: any[]) => any>( ): T => { return <T>((...args: any[]) => { // Promise.resolve added to handle non-promise commands - const cmdCall = () => Promise.resolve(applyCommandChanges(opts, cmdName, cmd, args)); + const cmdCall = () => + Promise.resolve(applyCommandChanges(opts, cmdName, cmd, args)); const finalCall = () => timeoutWrapper(cmdCall, opts.timeout) .then((res) => { @@ -111,8 +122,7 @@ const buildConnect: BuildConnectionFn = // Note !== false is used since useLatestUSPVersion is assumed to be true // needs version check only if no version was provided or useLatestUSPVersion is true const needsVersionCheck = - typeof options.version !== "string" || - options.useLatestUSPVersion !== false; + typeof options.version !== "string" || options.useLatestUSPVersion; let proto: Proto = await loadProtobuf(version); const router = makeRouter(); @@ -234,6 +244,8 @@ const buildConnect: BuildConnectionFn = const baseUSP: Partial<USP> = { get: (paths, options) => call("GET", { paths, options }), + getNested: (paths, options) => + call("GET", { paths, options: { ...options, retainPath: true } }), set: (path, value) => call("SET", { path, value }), add: (path, value) => call("ADD", { path, value }), del: (paths, allowPartial) => call("DELETE", { paths, allowPartial }), @@ -245,6 +257,10 @@ const buildConnect: BuildConnectionFn = on, ...makeRecipes(call, on), disconnect: () => client.end(), + getUSPVersion: () => version, + setUSPVersion: (v) => { + version = v; + }, }; if (needsVersionCheck) { diff --git a/src/types.ts b/src/types.ts index 4a9bc246fe1c8d55570d872ef308d77253820d24..4df44c65139b8f0529d79753f40a47070e9a7cf9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,23 +11,70 @@ export type CommandType = | "GET_INSTANCES" | "GET_SUPPORTED_PROTO"; -export type GetCommandOptions = { - raw?: boolean; +export type GetCommandGeneralOptions = { /** * Defaults to 2 (Only applies to usp version 1.2 ) */ max_depth?: number; +}; + +export type GetCommandOptions = { + raw?: boolean; /** - * All results will be nestled into { result: <result>, query: <full path> } (defaults to false) + * All results will be nestled into { result: <result>, __query__: <full path> } (defaults to false) + * @deprecated Use getNested instead */ retainPath?: boolean; -}; +} & GetCommandGeneralOptions; + +export type GetNestedCommandOptions = {} & GetCommandGeneralOptions; + +type ReturnValue = + | string + | { __query__: string; [key: string]: ReturnValue | ReturnValue[] } + | { __query__: string; [key: string]: ReturnValue | ReturnValue[] }[]; export type GetReturn = string | Record<string, any> | Record<string, any>[]; + +export type UspProperty = { + __query__: string; + result: string; +}; + +export type UspPropertyList = { + __query__: string; + result: { __query__: string; result: string }[]; +}; + +export type UspObject = { + __query__: string; + result: { __query__: string; [key: string]: ReturnValue }; +}; + +export type UspObjectList = { + __query__: string; + result: { __query__: string; [key: string]: ReturnValue }[]; +}; + +export type GetNestedReturn = + | UspProperty + | UspPropertyList + | UspObject + | UspObjectList; + export type GetCommand = ( paths: string | string[], options?: GetCommandOptions ) => Promise<GetReturn>; +export type GetNestedCommand = (<T = GetNestedReturn>( + path: string, + options?: GetNestedCommandOptions +) => Promise<T>) & + (<T = GetNestedReturn>( + paths: string[], + options?: GetNestedCommandOptions + ) => Promise<T[]>); + export type SetCommand = ( path: string | string[], value: @@ -88,6 +135,7 @@ export type PromiseClearFn = () => Promise<void>; export type Command = | GetCommand + | GetNestedCommand | SetCommand | AddCommand | DelCommand @@ -238,7 +286,8 @@ export type Options = { export interface USP { /** * Get value at path - * @param path Location of value (e.g. "Device.DeviceInfo.") + * @param path Location of value (e.g. "Device.DeviceInfo." or an array of multiple paths) + * @param options Get options (not required) * ``` * await usp.get("Device.WiFi.Radio.1.") * // or @@ -249,6 +298,30 @@ export interface USP { */ get: GetCommand; + /** + * Get value at path, returns values with their full path stored in "__query__" key + * @param path Location of value (e.g. "Device.DeviceInfo." or an array of multiple paths) + * @param options Get options (not required) + * ``` + * await usp.getNested("Device.WiFi.Radio.1.") + * + * await usp.getNested(["Device.WiFi.Radio.1.", "Device.WiFi.Radio.2."]) + * + * await usp.getNested("Device.WiFi.Radio.1.", { max_depth: 4 }) + * // providing return type + * await usp.getNested<UspProperty>("Device.WiFi.Radio.1.Alias") + * + * await usp.getNested<UspPropertyList>("Device.WiFi.Radio.*.Alias") + * + * await usp.getNested<UspObject>("Device.WiFi.Radio.1.") + * + * await usp.getNested<UspObjectList>("Device.WiFi.Radio.*.") + * + * await usp.getNested<UspProperty>(["Device.WiFi.Radio.1.Alias", "Device.WiFi.Radio.2.Alias"]) + * ``` + */ + getNested: GetNestedCommand; + /** * Set value at path * @param path Location of value (e.g. "Device.DeviceInfo.") @@ -404,6 +477,23 @@ export interface USP { * ``` */ options: (opts: Options) => USP; + /** + * Gets current usp version (used by usp-js library for parsing incoming messages) + * @returns USP Version + * ``` + * usp.getUspVersion() + * ``` + */ + getUSPVersion: () => USPVersion; + /** + * Sets current usp version (only applies to version used by the library, not the version used by the agent, mostly for testing purposes) + * To set the version used by the agent use the supportedProto function + * @returns USP Version + * ``` + * usp.setUspVersion("1.1") + * ``` + */ + setUSPVersion: (version: USPVersion) => void; } /** diff --git a/tests/integration/index.test.ts b/tests/integration/index.test.ts index be8e14df933f7705b6656cdaec26f4cabe8cf45f..d710939d2de48968c4e65ea9eb47a7cfb8791650 100644 --- a/tests/integration/index.test.ts +++ b/tests/integration/index.test.ts @@ -47,52 +47,44 @@ describe("Test general API", function () { }); it("get a value with retainPath returns value with path", async () => { - const resp = await device.get("Device.NAT.PortMapping.1.Alias", { - retainPath: true, - }); + const resp = await device.getNested("Device.NAT.PortMapping.1.Alias"); assert.strictEqual( typeof resp.result === "string" && - resp.query === "Device.NAT.PortMapping.1.Alias", + resp.__query__ === "Device.NAT.PortMapping.1.Alias", true ); }); it("get an array of values with retainPath returns values with paths", async () => { - const resp = await device.get("Device.NAT.PortMapping.*.Alias", { - retainPath: true, - }); + const resp = await device.getNested("Device.NAT.PortMapping.*.Alias"); assert.strictEqual( typeof resp.result === "object" && - resp.query === "Device.NAT.PortMapping.*.Alias" && + resp.__query__ === "Device.NAT.PortMapping.*.Alias" && Array.isArray(resp.result) && resp.result.every( - (v) => "query" in v && "result" in v && typeof v.result === "string" + (v) => "__query__" in v && "result" in v && typeof v.result === "string" ), true ); }); it("get an object with retainPath returns object with path", async () => { - const resp = await device.get("Device.NAT.PortMapping.1.", { - retainPath: true, - }); + const resp = await device.getNested("Device.NAT.PortMapping.1."); assert.strictEqual( typeof resp.result === "object" && - resp.query === "Device.NAT.PortMapping.1.", + resp.__query__ === "Device.NAT.PortMapping.1.", true ); }); it("get an array of object with retainPath returns all objects with paths", async () => { - const resp = await device.get("Device.NAT.PortMapping.", { - retainPath: true, - }); + const resp = await device.getNested("Device.NAT.PortMapping."); assert.strictEqual( typeof resp.result === "object" && - resp.query === "Device.NAT.PortMapping." && + resp.__query__ === "Device.NAT.PortMapping." && Array.isArray(resp.result) && resp.result.every( - (v) => "query" in v && "result" in v && typeof v.result === "object" + (v) => "__query__" in v && "result" in v && typeof v.result === "object" ), true );