|
| 1 | +// (C) Copyright 2015 Moodle Pty Ltd. |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +import { CoreLogger } from './logger'; |
| 16 | + |
| 17 | +/** |
| 18 | + * Singleton with helper to apply JSON patches. |
| 19 | + * Only supports 'add', 'remove' and 'replace' operations. |
| 20 | + * It supports some custom syntax to identify array entries besides using numeric indexes: |
| 21 | + * - [key=value]: search an object in the array where the property 'key' has the value 'value'. |
| 22 | + * - value: search an element in the array with the given value (only for arrays of primitive types). |
| 23 | + * |
| 24 | + * See the RFC 6902 for more information: https://datatracker.ietf.org/doc/html/rfc6902. |
| 25 | + */ |
| 26 | +export class CoreJsonPatch { |
| 27 | + |
| 28 | + protected static logger = CoreLogger.getInstance('CoreJsonPatch'); |
| 29 | + |
| 30 | + // Avoid creating singleton instances. |
| 31 | + private constructor() { |
| 32 | + // Nothing to do. |
| 33 | + } |
| 34 | + |
| 35 | + /** |
| 36 | + * Apply multiple JSON patches to an object or array. The original object/array is modified. |
| 37 | + * |
| 38 | + * @param objOrArray Object or array to apply the patches to. |
| 39 | + * @param patches Array of patch operations to apply. |
| 40 | + * @returns The modified object or array. |
| 41 | + */ |
| 42 | + static applyPatches<T = unknown>(objOrArray: T, patches: JsonPatchOperation[]): T { |
| 43 | + patches.forEach((patch) => { |
| 44 | + try { |
| 45 | + CoreJsonPatch.applyPatch(objOrArray, patch); |
| 46 | + } catch (error) { |
| 47 | + CoreJsonPatch.logger.error('Error applying patch:', error, patch); |
| 48 | + } |
| 49 | + }); |
| 50 | + |
| 51 | + return objOrArray; |
| 52 | + } |
| 53 | + |
| 54 | + /** |
| 55 | + * Apply a JSON patch operation to an object or array. The original object/array is modified. |
| 56 | + * |
| 57 | + * @param objOrArray Object or array to apply the patch to. |
| 58 | + * @param patch Patch operation to apply. |
| 59 | + * @returns The modified object or array. |
| 60 | + */ |
| 61 | + static applyPatch<T = unknown>(objOrArray: T, patch: JsonPatchOperation): T { |
| 62 | + if (patch.op !== 'add' && patch.op !== 'remove' && patch.op !== 'replace') { |
| 63 | + throw new Error(`Unsupported operation: ${patch.op}`); |
| 64 | + } |
| 65 | + |
| 66 | + const keys = patch.path.split('/'); |
| 67 | + |
| 68 | + let target = objOrArray; |
| 69 | + for (let i = 1; i < keys.length - 1; i++) { |
| 70 | + if (Array.isArray(target)) { |
| 71 | + const index = CoreJsonPatch.getArrayIndex(target, keys[i], false); |
| 72 | + target = target[index]; |
| 73 | + } else if (typeof target === 'object' && target !== null) { |
| 74 | + target = target[keys[i]]; |
| 75 | + } else { |
| 76 | + const type = target === null ? 'null' : typeof target; |
| 77 | + throw new Error(`Invalid path: ${patch.path}. '${keys[i]}' parent is not an object or an array: ${type}`); |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + if (Array.isArray(target)) { |
| 82 | + CoreJsonPatch.applyArrayOperation(target, keys[keys.length - 1], patch); |
| 83 | + } else if (typeof target === 'object' && target !== null) { |
| 84 | + CoreJsonPatch.applyObjectOperation(target as Record<string, unknown>, keys[keys.length - 1], patch); |
| 85 | + } else { |
| 86 | + const type = target === null ? 'null' : typeof target; |
| 87 | + throw new Error(`Invalid path: ${patch.path}. '${keys[keys.length - 2]}' parent is not an object or an array: ${type}`); |
| 88 | + } |
| 89 | + |
| 90 | + return objOrArray; |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * Apply an operation to an array. |
| 95 | + * |
| 96 | + * @param target Array to modify. |
| 97 | + * @param key Key of the element to modify. |
| 98 | + * @param patch Patch operation to apply. |
| 99 | + */ |
| 100 | + protected static applyArrayOperation(target: unknown[], key: string, patch: JsonPatchOperation): void { |
| 101 | + const index = CoreJsonPatch.getArrayIndex(target, key, patch.op === 'add'); |
| 102 | + |
| 103 | + switch (patch.op) { |
| 104 | + case 'add': |
| 105 | + target.splice(index, 0, patch.value); |
| 106 | + break; |
| 107 | + case 'remove': |
| 108 | + target.splice(index, 1); |
| 109 | + break; |
| 110 | + case 'replace': |
| 111 | + target[index] = patch.value; |
| 112 | + break; |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * Apply an operation to an array. |
| 118 | + * |
| 119 | + * @param target Array to modify. |
| 120 | + * @param key Key of the element to modify. |
| 121 | + * @param patch Patch operation to apply. |
| 122 | + */ |
| 123 | + protected static applyObjectOperation(target: Record<string, unknown>, key: string, patch: JsonPatchOperation): void { |
| 124 | + if (patch.op === 'add' || patch.op === 'replace') { |
| 125 | + target[key] = patch.value; |
| 126 | + } else if (patch.op === 'remove') { |
| 127 | + delete target[key]; |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Given a value of a path and an array, get the index of an element in an array. |
| 133 | + * |
| 134 | + * @param array Array to search the element in. |
| 135 | + * @param pathValue Value of the path used to get the index. |
| 136 | + * @param allowIndexEnd Whether to allow returning an index equal to array.length (used when adding values). |
| 137 | + * @returns Index of the element or null if not found. |
| 138 | + */ |
| 139 | + protected static getArrayIndex(array: unknown[], pathValue: string, allowIndexEnd = false): number { |
| 140 | + let index = parseInt(pathValue, 10); |
| 141 | + if (!isNaN(index)) { |
| 142 | + if (index < 0 || index > array.length || (index === array.length && !allowIndexEnd)) { |
| 143 | + throw new Error(`Numeric index ${pathValue} out of array bounds: ${JSON.stringify(array)}`); |
| 144 | + } |
| 145 | + |
| 146 | + return index; |
| 147 | + } |
| 148 | + |
| 149 | + // First check [key=value] format to search elements in the array. |
| 150 | + const matches = pathValue.match(/^\[([^=]+)=([^\]]+)\]$/); |
| 151 | + if (matches) { |
| 152 | + // When finding by key=value, assume the array is an array of objects. |
| 153 | + index = (<Record<string, unknown>[]> array).findIndex(item => String(item[matches[1]]) === matches[2]); |
| 154 | + if (index === -1) { |
| 155 | + throw new Error(`Element with ${matches[1]}=${matches[2]} not found in array: ${JSON.stringify(array)}`); |
| 156 | + } |
| 157 | + |
| 158 | + return index; |
| 159 | + } |
| 160 | + |
| 161 | + // Support identifying items by value in case of arrays of primitive types. |
| 162 | + index = array.findIndex(item => String(item) === pathValue); |
| 163 | + if (index === -1) { |
| 164 | + throw new Error(`Element with value ${pathValue} not found in array: ${JSON.stringify(array)}`); |
| 165 | + } |
| 166 | + |
| 167 | + return index; |
| 168 | + } |
| 169 | + |
| 170 | +} |
| 171 | + |
| 172 | +/** |
| 173 | + * Operation to patch a JSON. |
| 174 | + */ |
| 175 | +export type JsonPatchOperation = { |
| 176 | + op: 'add' | 'remove' | 'replace'; |
| 177 | + path: string; |
| 178 | + value?: unknown; |
| 179 | +}; |
0 commit comments