UNPKG

json-patch-query

Version:

Implementation of JSON Patch Query as proposed by the TM Forum

308 lines (305 loc) 12.4 kB
import { JSONPath } from 'jsonpath-plus'; import lodash from 'lodash'; const { get, set, unset, isEqual } = lodash; function resolveArrayPath(path) { let arrayPath = path; const pindex = path.match(/\d+/g)?.pop(); if (pindex) { const positionOfIndex = path.lastIndexOf(`${pindex}.`); arrayPath = path.substring(0, pindex.length + positionOfIndex); } return arrayPath; } function expandPaths(path, document, options) { const props = path.split("."); let expanded = /* @__PURE__ */ new Set(); let absolutePath = options.startingPath; props.forEach((prop, i, arr) => { absolutePath += absolutePath ? `.${prop}` : prop; if (Object.prototype.hasOwnProperty.call(document, prop)) { const propValue = document[prop]; const nextProp = arr.slice(i + 1); if (Array.isArray(propValue)) { propValue.forEach((arrVal, j) => { if (arrVal && arrVal === Object(arrVal)) { if (nextProp.length > 0) { const arrayValuePaths = expandPaths(nextProp.join("."), arrVal, { // pass the array index to the starting path startingPath: `${absolutePath}.${j}`, includeIndex: options.includeIndex }); expanded = /* @__PURE__ */ new Set([...expanded, ...arrayValuePaths]); } else if (options.includeIndex) { expanded.add(`${absolutePath}.${j}`); } else { expanded.add(absolutePath); } } else if (options.includeIndex) { expanded.add(`${absolutePath}.${j}`); } else { expanded.add(absolutePath); } }); } if (nextProp.length > 0) { const nestedPaths = expandPaths(nextProp.join("."), propValue, { // pass the array index to the starting path startingPath: `${absolutePath}`, includeIndex: options.includeIndex }); expanded = /* @__PURE__ */ new Set([...expanded, ...nestedPaths]); } else { expanded.add(absolutePath); } } }); return [...expanded]; } function findPaths(query, document) { const matched = []; let matchingValueInArray; Object.keys(query).forEach((param) => { const matchValue = query[param]; const expanded = expandPaths(param, document, { startingPath: "" }); const pathsThatMatched = expanded.filter((exp) => { const matchedValue = get(document, exp); if (Array.isArray(matchedValue)) { const index = matchedValue.findIndex((val) => val === matchValue); if (index === -1) { return false; } matchingValueInArray = `${exp}.${index}`; return true; } return matchedValue === matchValue; }); matched.push(pathsThatMatched); }); if (matched.length === 0) return []; if (matchingValueInArray) matched[0].push(matchingValueInArray); return matched.flatMap((matches) => matches.filter((mpath) => { const marrayPath = resolveArrayPath(mpath); return matched.every((marray) => marray.some((path) => { const arrayPath = resolveArrayPath(path); if (arrayPath.length >= marrayPath.length) { return path.includes(marrayPath); } return marrayPath.includes(arrayPath); })); })); } function resolveTMFPath(path, document, includeIndex = false) { const { searchParams, pathname } = new URL(path, "https://www.example.com"); const pathProps = pathname.replace(/\//g, ".").slice(1); const pathMap = Object.fromEntries(searchParams.entries()); if (Object.keys(pathMap).length === 0) { return [pathProps.split(".")]; } const expandedPaths = expandPaths(pathProps, document, { startingPath: "", includeIndex }); const matchedPaths = findPaths(pathMap, document); if (matchedPaths.length === 0) return []; return expandedPaths.filter((epath) => matchedPaths.every((mpath) => { const lastArrayIndex = mpath.match(/\d+/g)?.pop(); if (lastArrayIndex) { const positionOfIndex = mpath.lastIndexOf(`.${lastArrayIndex}`); const parent = mpath.substring(0, 1 + lastArrayIndex.length + positionOfIndex); return epath.includes(parent); } return epath.includes(mpath); })).map((s) => s.split(".")); } const REMOVED_ELEMENT = Symbol("removed element"); const ADDING_ELEMENT = Symbol("adding element"); class JSONPatchQuery { /** * Legacy Content-Type: application/json-patch-query+json * * Ideally this should not be used any longer, * instead application/json-patch+query should be used instead. * * @param document The document to apply the patch operation to * @param patch An array of patch operations to perform * @returns The modified document */ static applyLegacy(document, patch) { patch.forEach((operation) => { let paths = resolveTMFPath(operation.path, document, operation.op === "remove"); if (paths.length === 0 && operation.op === "add") { const { searchParams, pathname } = new URL(operation.path, "https://www.example.com"); const pathArray = pathname.split("/"); const addition = { key: pathArray.pop(), path: `${pathArray.join("/")}?${decodeURI(searchParams.toString().replace(/\+/g, "%20"))}` }; const additionPaths = resolveTMFPath(addition.path, document, true); if (additionPaths.length > 0 && addition.key) { additionPaths.forEach((additionPath) => { const element = get(document, additionPath); set(document, additionPath, { ...element, [addition.key]: void 0 }); }); paths = resolveTMFPath(operation.path, document); } } if (paths.length === 0) { throw new Error(`Provided JSON Path did not resolve any nodes, path: ${operation.path}`); } const modifiedArrays = /* @__PURE__ */ new Set(); paths.forEach((path) => { const element = get(document, path); const parentPath = path.slice(0, -1); const [elementKey] = path.slice(-1); const parent = get(document, parentPath); let newValue; switch (operation.op) { case "add": if (Array.isArray(element)) { newValue = [...element, operation.value]; } else if (typeof element === "object") { newValue = { ...element, ...operation.value }; } else { newValue = operation.value; } set(document, path, newValue); break; case "remove": if (Array.isArray(parent)) { parent.splice(parseInt(elementKey, 10), 1, REMOVED_ELEMENT); modifiedArrays.add(parentPath); set(document, parentPath, parent); } else { unset(document, path); } break; case "replace": set(document, path, operation.value); break; } }); if (modifiedArrays.size > 0) { const it = modifiedArrays.values(); let result = it.next(); while (!result.done) { const modifiedArrayPath = result.value; let modifiedArray = get(document, modifiedArrayPath); modifiedArray = modifiedArray.filter((val) => val !== REMOVED_ELEMENT); set(document, modifiedArrayPath, modifiedArray); result = it.next(); } } }); return document; } /** * Content-Type: application/json-patch+query * @param document The document to apply the patch operation to * @param patch An array of patch operations to perform * @returns The modified document */ static apply(document, patch) { patch.forEach((operation) => this.applyOperation(document, operation)); if (Array.isArray(document)) { return document.filter((val) => val !== REMOVED_ELEMENT); } return document; } static applyOperation(document, operation) { if (operation.op === "copy") { const value = this.applyOperation(document, { op: "_get", path: operation.from }); this.applyOperation(document, { op: "add", path: operation.path, value }); return; } if (operation.op === "move") { const value = this.applyOperation(document, { op: "_get", path: operation.from }); this.applyOperation(document, { op: "remove", path: operation.from }); this.applyOperation(document, { op: "add", path: operation.path, value }); return; } const results = JSONPath({ path: operation.path, json: document, resultType: "path" }); let paths = results.map((result) => JSONPath.toPathArray(result)); if (paths.length === 0 && operation.op === "add") { if (!/^(.*)\.[`\w@#\-$]*$/i.test(operation.path)) { throw new Error(`Provided JSON Path did not resolve any nodes, path: ${operation.path}`); } const pathArray = operation.path.split("."); const addition = { key: pathArray.pop()?.replace(/^`/, ""), path: pathArray.join(".") }; const addResults = JSONPath({ path: addition.path, json: document, resultType: "path" }); const additionPaths = addResults.map((result) => JSONPath.toPathArray(result)); if (additionPaths.length > 0 && addition.key) { const additionPath = additionPaths[0].filter((p) => p !== "$"); if (additionPath.length > 0) { const element = get(document, additionPath); set(document, additionPath, { ...element, [addition.key]: ADDING_ELEMENT }); const match = JSONPath({ path: operation.path, json: document, resultType: "path" }); paths = match.map((result) => JSONPath.toPathArray(result)); } else { set(document, addition.key, ADDING_ELEMENT); const match = JSONPath({ path: operation.path, json: document, resultType: "path" }); paths = match.map((result) => JSONPath.toPathArray(result)); } } } if (paths.length === 0) { throw new Error(`Provided JSON Path did not resolve any nodes, path: ${operation.path}`); } if (operation.op === "_get") { if (paths.length > 1) { throw new Error(`Provided JSON Path "from" value resolved multiple nodes. Ensure the path only resolves to one node, path: ${operation.path}`); } const path = paths[0].filter((p) => p !== "$"); return get(document, path); } const modifiedArrays = /* @__PURE__ */ new Set(); paths.forEach((_path) => { const path = _path.filter((p) => p !== "$"); const element = get(document, path); const parentPath = path.slice(0, -1); const [elementKey] = path.slice(-1); const parent = get(document, parentPath); let newValue; switch (operation.op) { case "add": if (Array.isArray(element)) { newValue = [...element, operation.value]; } else if (typeof element === "object" && typeof operation.value === "object") { newValue = { ...element, ...operation.value }; } else { newValue = operation.value; } set(document, path, newValue); break; case "remove": if (Array.isArray(parent)) { parent.splice(parseInt(elementKey, 10), 1, REMOVED_ELEMENT); modifiedArrays.add(parentPath); set(document, parentPath, parent); } else if (Array.isArray(document) && !parent) { modifiedArrays.add([]); set(document, path, REMOVED_ELEMENT); } else { unset(document, path); } break; case "replace": set(document, path, operation.value); break; case "test": if (!isEqual(get(document, path), operation.value)) { throw new Error(`test operation failed, seeking value: ${JSON.stringify(operation.value)} at path: ${operation.path}`); } break; } }); if (modifiedArrays.size > 0) { const it = modifiedArrays.values(); let result = it.next(); while (!result.done) { const modifiedArrayPath = result.value; let modifiedArray = get(document, modifiedArrayPath, document); modifiedArray = modifiedArray.filter((val) => val !== REMOVED_ELEMENT); set(document, modifiedArrayPath, modifiedArray); result = it.next(); } } } } export { JSONPatchQuery as default, expandPaths, findPaths, resolveTMFPath };