json-patch-query
Version:
Implementation of JSON Patch Query as proposed by the TM Forum
319 lines (312 loc) • 12.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
const jsonpathPlus = require('jsonpath-plus');
const lodash = require('lodash');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
const lodash__default = /*#__PURE__*/_interopDefaultCompat(lodash);
const { get, set, unset, isEqual } = lodash__default;
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 = jsonpathPlus.JSONPath({ path: operation.path, json: document, resultType: "path" });
let paths = results.map((result) => jsonpathPlus.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 = jsonpathPlus.JSONPath({ path: addition.path, json: document, resultType: "path" });
const additionPaths = addResults.map((result) => jsonpathPlus.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 = jsonpathPlus.JSONPath({ path: operation.path, json: document, resultType: "path" });
paths = match.map((result) => jsonpathPlus.JSONPath.toPathArray(result));
} else {
set(document, addition.key, ADDING_ELEMENT);
const match = jsonpathPlus.JSONPath({ path: operation.path, json: document, resultType: "path" });
paths = match.map((result) => jsonpathPlus.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();
}
}
}
}
exports.default = JSONPatchQuery;
exports.expandPaths = expandPaths;
exports.findPaths = findPaths;
exports.resolveTMFPath = resolveTMFPath;