diff --git a/src/core/classes/sites/authenticated-site.ts b/src/core/classes/sites/authenticated-site.ts index 1d5976d8cf4..9989f9d210f 100644 --- a/src/core/classes/sites/authenticated-site.ts +++ b/src/core/classes/sites/authenticated-site.ts @@ -465,8 +465,13 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite { } const observable = this.performRequest(method, data, preSets, wsPreSets).pipe( - // Return a clone of the original object, this may prevent errors if in the callback the object is modified. - map((data) => CoreUtils.clone(data)), + map((data) => { + // Always clone the object because it can be modified when applying patches or in the caller function + // and we don't want to store the modified object in cache. + const clonedData = CoreUtils.clone(data); + + return this.applyWSOverrides(method, clonedData); + }), ); this.setOngoingRequest(cacheId, preSets, observable); @@ -1347,13 +1352,13 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite { * @inheritdoc */ async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise { + const method = 'tool_mobile_get_public_config'; const ignoreCache = options.readingStrategy === CoreSitesReadingStrategy.ONLY_NETWORK || options.readingStrategy === CoreSitesReadingStrategy.PREFER_NETWORK; if (!ignoreCache && this.publicConfig) { - return this.publicConfig; + return this.overridePublicConfig(this.publicConfig); } - const method = 'tool_mobile_get_public_config'; const cacheId = this.getCacheId(method, {}); const cachePreSets: CoreSiteWSPreSets = { getFromCache: true, @@ -1378,8 +1383,7 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite { const subject = new Subject(); const observable = subject.pipe( - // Return a clone of the original object, this may prevent errors if in the callback the object is modified. - map((data) => CoreUtils.clone(data)), + map((data) => this.overridePublicConfig(data)), finalize(() => { this.clearOngoingRequest(cacheId, cachePreSets, observable); }), diff --git a/src/core/classes/sites/unauthenticated-site.ts b/src/core/classes/sites/unauthenticated-site.ts index 70093f1ddf3..c2594c17695 100644 --- a/src/core/classes/sites/unauthenticated-site.ts +++ b/src/core/classes/sites/unauthenticated-site.ts @@ -20,6 +20,8 @@ import { CoreText } from '@singletons/text'; import { CoreUrl, CoreUrlPartNames } from '@singletons/url'; import { CoreWS, CoreWSAjaxPreSets, CoreWSExternalWarning } from '@services/ws'; import { CorePath } from '@singletons/path'; +import { CoreJsonPatch } from '@singletons/json-patch'; +import { CoreUtils } from '@singletons/utils'; /** * Class that represents a Moodle site where the user still hasn't authenticated. @@ -251,7 +253,7 @@ export class CoreUnauthenticatedSite { const ignoreCache = options.readingStrategy === CoreSitesReadingStrategy.ONLY_NETWORK || options.readingStrategy === CoreSitesReadingStrategy.PREFER_NETWORK; if (!ignoreCache && this.publicConfig) { - return this.publicConfig; + return this.overridePublicConfig(this.publicConfig); } if (options.readingStrategy === CoreSitesReadingStrategy.ONLY_CACHE) { @@ -263,7 +265,7 @@ export class CoreUnauthenticatedSite { this.setPublicConfig(config); - return config; + return this.overridePublicConfig(config); } catch (error) { if (options.readingStrategy === CoreSitesReadingStrategy.ONLY_NETWORK || !this.publicConfig) { throw error; @@ -284,6 +286,20 @@ export class CoreUnauthenticatedSite { this.publicConfig = publicConfig; } + /** + * Apply overrides to the public config of the site. + * + * @param config Public config. + * @returns Public config with overrides if any. + */ + protected overridePublicConfig(config: CoreSitePublicConfigResponse): CoreSitePublicConfigResponse { + // Always clone the object because it can be modified when applying patches or in the caller function + // and we don't want to modify the stored public config. + const clonedData = CoreUtils.clone(config); + + return this.applyWSOverrides('tool_mobile_get_public_config', clonedData); + } + /** * Perform a request to the server to get the public config of this site. * @@ -452,6 +468,40 @@ export class CoreUnauthenticatedSite { return features; } + /** + * Call a Moodle WS using the AJAX API and applies WebService overrides (if any) to the result. + * + * @param method WS method name. + * @param data Arguments to pass to the method. + * @param preSets Extra settings and information. + * @returns Promise resolved with the response data in success and rejected with CoreAjaxError. + */ + async callAjax( + method: string, + data: Record = {}, + preSets: Omit = {}, + ): Promise { + const result = await CoreWS.callAjax(method, data, { ...preSets, siteUrl: this.siteUrl }); + + // No need to clone the data in this case because it's not stored in any cache. + return this.applyWSOverrides(method, result); + } + + /** + * Apply WS overrides (if any) to the data of a WebService response. + * + * @param method WS method name. + * @param data WS response data. + * @returns Modified data (or original data if no overrides). + */ + protected applyWSOverrides(method: string, data: T): T { + if (!CoreConstants.CONFIG.wsOverrides || !CoreConstants.CONFIG.wsOverrides[method]) { + return data; + } + + return CoreJsonPatch.applyPatches(data, CoreConstants.CONFIG.wsOverrides[method]); + } + } /** diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index 1f18c65b457..e3aa8ee26fd 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -16,7 +16,7 @@ import { Component, ElementRef, OnInit, ChangeDetectorRef, inject, viewChild } f import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { CoreText } from '@singletons/text'; import { CoreCountries, CoreCountry } from '@singletons/countries'; -import { CoreWS, CoreWSExternalWarning } from '@services/ws'; +import { CoreWSExternalWarning } from '@services/ws'; import { Translate } from '@singletons'; import { CoreSitePublicConfigResponse, CoreUnauthenticatedSite } from '@classes/sites/unauthenticated-site'; import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; @@ -197,10 +197,8 @@ export default class CoreLoginEmailSignupPage implements OnInit { if (this.ageDigitalConsentVerification === undefined) { const result = await CorePromiseUtils.ignoreErrors( - CoreWS.callAjax( + this.site.callAjax( 'core_auth_is_age_digital_consent_verification_enabled', - {}, - { siteUrl: this.site.getURL() }, ), ); @@ -344,10 +342,9 @@ export default class CoreLoginEmailSignupPage implements OnInit { this.signupForm.value, ); - const result = await CoreWS.callAjax( + const result = await this.site.callAjax( 'auth_email_signup_user', params, - { siteUrl: this.site.getURL() }, ); if (result.success) { @@ -430,7 +427,7 @@ export default class CoreLoginEmailSignupPage implements OnInit { params.age = parseInt(params.age, 10); // Use just the integer part. try { - const result = await CoreWS.callAjax('core_auth_is_minor', params, { siteUrl: this.site.getURL() }); + const result = await this.site.callAjax('core_auth_is_minor', params); CoreForms.triggerFormSubmittedEvent(this.ageFormElement(), true); diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index a08574fc35b..4598243ff05 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -20,7 +20,7 @@ import { CoreApp, CoreStoreConfig } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreEvents, CoreEventSessionExpiredData, CoreEventSiteData } from '@singletons/events'; import { CoreSites, CoreLoginSiteInfo, CoreSiteBasicInfo } from '@services/sites'; -import { CoreWS, CoreWSExternalWarning } from '@services/ws'; +import { CoreWSExternalWarning } from '@services/ws'; import { CoreText, CoreTextFormat } from '@singletons/text'; import { CoreObject } from '@singletons/object'; import { CoreConstants } from '@/core/constants'; @@ -65,6 +65,7 @@ import { CorePromiseUtils } from '@singletons/promise-utils'; import { CoreOpener } from '@singletons/opener'; import { CoreAlerts } from '@services/overlays/alerts'; import { CorePrompts } from '@services/overlays/prompts'; +import { CoreSitesFactory } from '@services/sites-factory'; /** * Helper provider that provides some common features regarding authentication. @@ -271,7 +272,7 @@ export class CoreLoginHelperProvider { * @returns Signup settings. */ async getEmailSignupSettings(siteUrl: string): Promise { - return await CoreWS.callAjax('auth_email_get_signup_settings', {}, { siteUrl }); + return await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('auth_email_get_signup_settings'); } /** @@ -830,7 +831,7 @@ export class CoreLoginHelperProvider { params.email = email.trim().toLowerCase(); } - return CoreWS.callAjax('core_auth_request_password_reset', params, { siteUrl }); + return CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('core_auth_request_password_reset', params); } /** @@ -1038,13 +1039,11 @@ export class CoreLoginHelperProvider { // Call the WS to resend the confirmation email. const modal = await CoreLoadings.show('core.sending', true); const data = { username: username?.toLowerCase(), password }; - const preSets = { siteUrl }; try { - const result = await CoreWS.callAjax( + const result = await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax( 'core_auth_resend_confirmation_email', data, - preSets, ); if (!result.status) { @@ -1077,7 +1076,7 @@ export class CoreLoginHelperProvider { // We don't have site info before login, the only way to check if the WS is available is by calling it. try { // This call will always fail because we aren't sending parameters. - await CoreWS.callAjax('core_auth_resend_confirmation_email', {}, { siteUrl }); + await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('core_auth_resend_confirmation_email'); return true; // We should never reach here. } catch (error) { diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index 3690de8aebb..6dc165c4dea 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -132,7 +132,7 @@ export class CoreWSProvider { * * @param method The WebService method to be called. * @param data Arguments to pass to the method. - * @param preSets Extra settings and information. Only some + * @param preSets Extra settings and information. * @returns Promise resolved with the response data in success and rejected with CoreAjaxError. */ callAjax(method: string, data: Record, preSets: CoreWSAjaxPreSets): Promise { diff --git a/src/core/singletons/json-patch.ts b/src/core/singletons/json-patch.ts new file mode 100644 index 00000000000..33586ca048b --- /dev/null +++ b/src/core/singletons/json-patch.ts @@ -0,0 +1,187 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreLogger } from './logger'; + +/** + * Singleton with helper to apply JSON patches. + * Only supports 'add', 'remove' and 'replace' operations. + * It supports some custom syntax to identify array entries besides using numeric indexes: + * - [key=value]: search an object in the array where the property 'key' has the value 'value'. + * - value: search an element in the array with the given value (only for arrays of primitive types). + * + * See the RFC 6902 for more information: https://datatracker.ietf.org/doc/html/rfc6902. + */ +export class CoreJsonPatch { + + protected static logger = CoreLogger.getInstance('CoreJsonPatch'); + + // Avoid creating singleton instances. + private constructor() { + // Nothing to do. + } + + /** + * Apply multiple JSON patches to an object or array. The original object/array is modified. + * + * @param objOrArray Object or array to apply the patches to. + * @param patches Array of patch operations to apply. + * @returns The modified object or array. + */ + static applyPatches(objOrArray: T, patches: JsonPatchOperation[]): T { + patches.forEach((patch) => { + try { + CoreJsonPatch.applyPatch(objOrArray, patch); + } catch (error) { + CoreJsonPatch.logger.error('Error applying patch:', error, patch); + } + }); + + return objOrArray; + } + + /** + * Apply a JSON patch operation to an object or array. The original object/array is modified. + * + * @param objOrArray Object or array to apply the patch to. + * @param patch Patch operation to apply. + * @returns The modified object or array. + */ + static applyPatch(objOrArray: T, patch: JsonPatchOperation): T { + if (patch.op !== 'add' && patch.op !== 'remove' && patch.op !== 'replace') { + throw new Error(`Unsupported operation: ${patch.op}`); + } + + const keys = patch.path.split('/'); + + let target = objOrArray; + for (let i = 1; i < keys.length - 1; i++) { + if (Array.isArray(target)) { + const index = CoreJsonPatch.getArrayIndex(target, keys[i], false); + target = target[index]; + } else if (typeof target === 'object' && target !== null) { + target = target[keys[i]]; + } else { + const type = target === null ? 'null' : typeof target; + throw new Error(`Invalid path: ${patch.path}. '${keys[i]}' parent is not an object or an array: ${type}`); + } + } + + if (Array.isArray(target)) { + CoreJsonPatch.applyArrayOperation(target, keys[keys.length - 1], patch); + } else if (typeof target === 'object' && target !== null) { + CoreJsonPatch.applyObjectOperation(target as Record, keys[keys.length - 1], patch); + } else { + const type = target === null ? 'null' : typeof target; + throw new Error(`Invalid path: ${patch.path}. '${keys[keys.length - 2]}' parent is not an object or an array: ${type}`); + } + + return objOrArray; + } + + /** + * Apply an operation to an array. + * + * @param target Array to modify. + * @param key Key of the element to modify. + * @param patch Patch operation to apply. + */ + protected static applyArrayOperation(target: unknown[], key: string, patch: JsonPatchOperation): void { + const index = CoreJsonPatch.getArrayIndex(target, key, patch.op === 'add'); + + switch (patch.op) { + case 'add': + target.splice(index, 0, patch.value); + break; + case 'remove': + target.splice(index, 1); + break; + case 'replace': + target[index] = patch.value; + break; + } + } + + /** + * Apply an operation to an array. + * + * @param target Array to modify. + * @param key Key of the element to modify. + * @param patch Patch operation to apply. + */ + protected static applyObjectOperation(target: Record, key: string, patch: JsonPatchOperation): void { + if (patch.op === 'add' || patch.op === 'replace') { + target[key] = patch.value; + } else if (patch.op === 'remove') { + delete target[key]; + } + } + + /** + * Given a value of a path and an array, get the index of an element in an array. + * + * @param array Array to search the element in. + * @param pathValue Value of the path used to get the index. + * @param allowIndexEnd Whether to allow returning an index equal to array.length (used when adding values). + * @returns Index of the element or null if not found. + */ + protected static getArrayIndex(array: unknown[], pathValue: string, allowIndexEnd = false): number { + if (pathValue === '-') { + if (!allowIndexEnd) { + throw new Error('Using \'-\' is only allowed when adding elements to the end of the array.'); + } + + return array.length; + } + + let index = parseInt(pathValue, 10); + if (!isNaN(index)) { + if (index < 0 || index >= array.length) { + throw new Error(`Numeric index ${pathValue} out of array bounds: ${JSON.stringify(array)}`); + } + + return index; + } + + // First check [key=value] format to search elements in the array. + const matches = pathValue.match(/^\[([^=]+)=([^\]]+)\]$/); + if (matches) { + // When finding by key=value, assume the array is an array of objects. + index = ([]> array).findIndex(item => String(item[matches[1]]) === matches[2]); + if (index === -1) { + throw new Error(`Element with ${matches[1]}=${matches[2]} not found in array: ${JSON.stringify(array)}`); + } + + return index; + } + + // Support identifying items by value in case of arrays of primitive types. + index = array.findIndex(item => String(item) === pathValue); + if (index === -1) { + throw new Error(`Element with value ${pathValue} not found in array: ${JSON.stringify(array)}`); + } + + return index; + } + +} + +/** + * Operation to patch a JSON. + */ +export type JsonPatchOperation = { + op: 'add' | 'remove' | 'replace'; + path: string; + value?: unknown; +}; diff --git a/src/core/singletons/tests/json-patch.test.ts b/src/core/singletons/tests/json-patch.test.ts new file mode 100644 index 00000000000..4280e17a2c8 --- /dev/null +++ b/src/core/singletons/tests/json-patch.test.ts @@ -0,0 +1,190 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreJsonPatch, JsonPatchOperation } from '@singletons/json-patch'; + +class NoErrorThrownError extends Error {} + +describe('CoreJsonPatch singleton', () => { + + // To test: + // scenario to test applyPatch with multiple patches + // scenario to test applyPatches, incuding a failing patch to confirm it doesn't stop the others + // Scenario to test array patches with [key=value] syntax + // Scenario to test array patches with primitive values + + it('should apply add operation', () => { + const obj = { a: 1 }; + + CoreJsonPatch.applyPatch(obj, { op: 'add', path: '/b', value: 2 }); + expect(obj).toEqual({ a: 1, b: 2 }); + + CoreJsonPatch.applyPatch(obj, { op: 'add', path: '/c', value: 3 }); + expect(obj).toEqual({ a: 1, b: 2, c: 3 }); + + const arr = [1, 2, 3]; + + CoreJsonPatch.applyPatch(arr, { op: 'add', path: '/1', value: 1.5 }); + expect(arr).toEqual([1, 1.5, 2, 3]); + + CoreJsonPatch.applyPatch(arr, { op: 'add', path: '/0', value: 0 }); + expect(arr).toEqual([0, 1, 1.5, 2, 3]); + + CoreJsonPatch.applyPatch(arr, { op: 'add', path: '/-', value: 4 }); + expect(arr).toEqual([0, 1, 1.5, 2, 3, 4]); + }); + + it('should apply replace operation', () => { + const obj = { a: 1, b: 2, c: { d: 4 } }; + + CoreJsonPatch.applyPatch(obj, { op: 'replace', path: '/b', value: 3 }); + expect(obj).toEqual({ a: 1, b: 3, c: { d: 4 } }); + + CoreJsonPatch.applyPatch(obj, { op: 'replace', path: '/c', value: 4 }); + expect(obj).toEqual({ a: 1, b: 3, c: 4 }); + + CoreJsonPatch.applyPatch(obj, { op: 'replace', path: '/c', value: { z: 6 } }); + expect(obj).toEqual({ a: 1, b: 3, c: { z: 6 } }); + + const arr = [1, 2, 3]; + + CoreJsonPatch.applyPatch(arr, { op: 'replace', path: '/0', value: 1.5 }); + expect(arr).toEqual([1.5, 2, 3]); + + CoreJsonPatch.applyPatch(arr, { op: 'replace', path: '/2', value: { value: 3.5 } }); + expect(arr).toEqual([1.5, 2, { value: 3.5 }]); + }); + + it('should apply remove operation', () => { + const obj = { a: 1, b: 2, c: 3 }; + + CoreJsonPatch.applyPatch(obj, { op: 'remove', path: '/b' }); + expect(obj).toEqual({ a: 1, c: 3 }); + + CoreJsonPatch.applyPatch(obj, { op: 'remove', path: '/a' }); + expect(obj).toEqual({ c: 3 }); + + const arr = [1, 2, 3]; + + CoreJsonPatch.applyPatch(arr, { op: 'remove', path: '/1' }); + expect(arr).toEqual([1, 3]); + + CoreJsonPatch.applyPatch(arr, { op: 'remove', path: '/0' }); + expect(arr).toEqual([3]); + }); + + it('should apply operations to nested items and arrays', () => { + const obj = { a: 1, childobj: { aa: 1, subchildobj: { aaa: 1 } } }; + + CoreJsonPatch.applyPatch(obj, { op: 'add', path: '/childobj/bb', value: 2 }); + expect(obj).toEqual({ a: 1, childobj: { aa: 1, bb: 2, subchildobj: { aaa: 1 } } }); + + CoreJsonPatch.applyPatch(obj, { op: 'add', path: '/childobj/subchildobj/bbb', value: 2 }); + expect(obj).toEqual({ a: 1, childobj: { aa: 1, bb: 2, subchildobj: { aaa: 1, bbb: 2 } } }); + + const arr = [{ id: 1, items: ['a'] }, { id: 2, items: ['1'] }, { id: 3, items: [] }]; + + CoreJsonPatch.applyPatch(arr, { op: 'add', path: '/0/items/-', value: 'b' }); + expect(arr).toEqual([{ id: 1, items: ['a', 'b'] }, { id: 2, items: ['1'] }, { id: 3, items: [] }]); + + CoreJsonPatch.applyPatch(arr, { op: 'add', path: '/2/items/-', value: 'foo' }); + expect(arr).toEqual([{ id: 1, items: ['a', 'b'] }, { id: 2, items: ['1'] }, { id: 3, items: ['foo'] }]); + }); + + it('can search elements in arrays using key=value syntax', () => { + const arr = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + CoreJsonPatch.applyPatch(arr, { op: 'replace', path: '/[id=2]', value: { id: 2, a: 1 } }); + expect(arr).toEqual([{ id: 1 }, { id: 2, a: 1 }, { id: 3 }]); + + CoreJsonPatch.applyPatch(arr, { op: 'remove', path: '/[id=3]' }); + expect(arr).toEqual([{ id: 1 }, { id: 2, a: 1 }]); + + // It can also be used to identify nested elements. + const obj = { items: [{ id: 1, subitems: ['a'] }, { id: 2, subitems: ['1'] }, { id: 3, subitems: [] }] }; + + CoreJsonPatch.applyPatch(obj, { op: 'add', path: '/items/[id=2]/subitems/-', value: '2' }); + expect(obj).toEqual({ items: [{ id: 1, subitems: ['a'] }, { id: 2, subitems: ['1', '2'] }, { id: 3, subitems: [] }] }); + + CoreJsonPatch.applyPatch(obj, { op: 'remove', path: '/items/[id=1]/subitems/0' }); + expect(obj).toEqual({ items: [{ id: 1, subitems: [] }, { id: 2, subitems: ['1', '2'] }, { id: 3, subitems: [] }] }); + + CoreJsonPatch.applyPatch(obj, { op: 'replace', path: '/items/[id=3]/id', value: 4 }); + expect(obj).toEqual({ items: [{ id: 1, subitems: [] }, { id: 2, subitems: ['1', '2'] }, { id: 4, subitems: [] }] }); + }); + + it('can search elements in arrays using primitive values', () => { + const stringArr = ['a', 'b', 'c']; + + CoreJsonPatch.applyPatch(stringArr, { op: 'replace', path: '/b', value: 'beta' }); + expect(stringArr).toEqual(['a', 'beta', 'c']); + + CoreJsonPatch.applyPatch(stringArr, { op: 'remove', path: '/a' }); + expect(stringArr).toEqual(['beta', 'c']); + }); + + it('should throw an error for invalid operations', () => { + const captureError = (call: () => unknown): Error => { + try { + call(); + + throw new NoErrorThrownError(); + } catch (error) { + return error; + } + }; + + // Path contains a property that doesn't exist. + let error = captureError(() => CoreJsonPatch.applyPatch({}, { op: 'add', path: '/a/b', value: 1 })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + error = captureError(() => CoreJsonPatch.applyPatch({ a: 1 }, { op: 'remove', path: '/a/b' })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + + // Index out of bounds. + error = captureError(() => CoreJsonPatch.applyPatch([1], { op: 'add', path: '/1', value: 2 })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + error = captureError(() => CoreJsonPatch.applyPatch([1], { op: 'add', path: '/-1', value: 0 })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + error = captureError(() => CoreJsonPatch.applyPatch([1], { op: 'remove', path: '/1' })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + + // Use - when searching in an array. + error = captureError(() => CoreJsonPatch.applyPatch([{ id: 1 }], { op: 'replace', path: '/-/id', value: 2 })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + + // Element not found using key=value syntax. + error = captureError(() => CoreJsonPatch.applyPatch([{ id: 1 }], { op: 'remove', path: '/[id=2]' })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + + // Element not found using primitive value. + error = captureError(() => CoreJsonPatch.applyPatch(['a'], { op: 'remove', path: '/b' })); + expect(error).not.toBeInstanceOf(NoErrorThrownError); + }); + + it('applyPatches should continue applying patches even if one fails', () => { + const obj = { a: 1, b: 2, c: 3, items: [] }; + + const patches: JsonPatchOperation[] = [ + { op: 'replace', path: '/a', value: 10 }, + { op: 'remove', path: '/d' }, // This will fail. + { op: 'replace', path: '/b', value: 20 }, + { op: 'replace', path: '/items/1', value: 'a' }, // This will fail. + { op: 'replace', path: '/c', value: 30 }, + ]; + + CoreJsonPatch.applyPatches(obj, patches); + expect(obj).toEqual({ a: 10, b: 20, c: 30, items: [] }); + }); + +}); diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 2ea72b85b89..5ea52f2ba8b 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -19,6 +19,7 @@ import { OpenFileAction } from '@singletons/opener'; import { CoreLoginSiteFinderSettings, CoreLoginSiteSelectorListMethod } from '@features/login/services/login-helper'; import { CoreDatabaseConfiguration } from '@classes/database/database-table'; import { ToastDuration } from '@services/overlays/toasts'; +import { JsonPatchOperation } from '@singletons/json-patch'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -80,4 +81,5 @@ export interface EnvironmentConfig { clearIABSessionWhenAutoLogin?: 'android' | 'ios' | 'all'; // Clear the session every time a new IAB is opened with auto-login. disabledFeatures?: string; // Disabled features for the whole app, using the same format as tool_mobile_disabledfeatures. collapsibleItemsExpanded: boolean; // Expand or collapse the collapsible items by default. + wsOverrides: Record; // Overrides to apply to WS calls. }