Skip to content

Commit 7659cdf

Browse files
committed
MOBILE-4919 core: Allow overriding WS response via config
1 parent b18e58f commit 7659cdf

File tree

8 files changed

+430
-21
lines changed

8 files changed

+430
-21
lines changed

src/core/classes/sites/authenticated-site.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,13 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite {
465465
}
466466

467467
const observable = this.performRequest<T>(method, data, preSets, wsPreSets).pipe(
468-
// Return a clone of the original object, this may prevent errors if in the callback the object is modified.
469-
map((data) => CoreUtils.clone(data)),
468+
map((data) => {
469+
// Always clone the object because it can be modified when applying patches or in the caller function
470+
// and we don't want to store the modified object in cache.
471+
const clonedData = CoreUtils.clone(data);
472+
473+
return this.applyWSOverrides(method, clonedData);
474+
}),
470475
);
471476

472477
this.setOngoingRequest(cacheId, preSets, observable);
@@ -1347,13 +1352,17 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite {
13471352
* @inheritdoc
13481353
*/
13491354
async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise<CoreSitePublicConfigResponse> {
1355+
const method = 'tool_mobile_get_public_config';
13501356
const ignoreCache = options.readingStrategy === CoreSitesReadingStrategy.ONLY_NETWORK ||
13511357
options.readingStrategy === CoreSitesReadingStrategy.PREFER_NETWORK;
13521358
if (!ignoreCache && this.publicConfig) {
1353-
return this.publicConfig;
1359+
// Always clone the object because it can be modified when applying patches or in the caller function
1360+
// and we don't want to modify the stored public config.
1361+
const clonedData = CoreUtils.clone(this.publicConfig);
1362+
1363+
return this.applyWSOverrides(method, clonedData);
13541364
}
13551365

1356-
const method = 'tool_mobile_get_public_config';
13571366
const cacheId = this.getCacheId(method, {});
13581367
const cachePreSets: CoreSiteWSPreSets = {
13591368
getFromCache: true,
@@ -1378,8 +1387,13 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite {
13781387

13791388
const subject = new Subject<CoreSitePublicConfigResponse>();
13801389
const observable = subject.pipe(
1381-
// Return a clone of the original object, this may prevent errors if in the callback the object is modified.
1382-
map((data) => CoreUtils.clone(data)),
1390+
map((data) => {
1391+
// Always clone the object because it can be modified when applying patches or in the caller function
1392+
// and we don't want to modify the stored public config.
1393+
const clonedData = CoreUtils.clone(data);
1394+
1395+
return this.applyWSOverrides(method, clonedData);
1396+
}),
13831397
finalize(() => {
13841398
this.clearOngoingRequest(cacheId, cachePreSets, observable);
13851399
}),

src/core/classes/sites/unauthenticated-site.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { CoreText } from '@singletons/text';
2020
import { CoreUrl, CoreUrlPartNames } from '@singletons/url';
2121
import { CoreWS, CoreWSAjaxPreSets, CoreWSExternalWarning } from '@services/ws';
2222
import { CorePath } from '@singletons/path';
23+
import { CoreJsonPatch } from '@singletons/json-patch';
2324

2425
/**
2526
* Class that represents a Moodle site where the user still hasn't authenticated.
@@ -452,6 +453,40 @@ export class CoreUnauthenticatedSite {
452453
return features;
453454
}
454455

456+
/**
457+
* Call a Moodle WS using the AJAX API and applies WebService overrides (if any) to the result.
458+
*
459+
* @param method WS method name.
460+
* @param data Arguments to pass to the method.
461+
* @param preSets Extra settings and information.
462+
* @returns Promise resolved with the response data in success and rejected with CoreAjaxError.
463+
*/
464+
async callAjax<T = unknown>(
465+
method: string,
466+
data: Record<string, unknown> = {},
467+
preSets: Omit<CoreWSAjaxPreSets, 'siteUrl'> = {},
468+
): Promise<T> {
469+
const result = await CoreWS.callAjax<T>(method, data, { ...preSets, siteUrl: this.siteUrl });
470+
471+
// No need to clone the data in this case because it's not stored in any cache.
472+
return this.applyWSOverrides(method, result);
473+
}
474+
475+
/**
476+
* Apply WS overrides (if any) to the data of a WebService response.
477+
*
478+
* @param method WS method name.
479+
* @param data WS response data.
480+
* @returns Modified data (or original data if no overrides).
481+
*/
482+
protected applyWSOverrides<T>(method: string, data: T): T {
483+
if (!CoreConstants.CONFIG.wsOverrides || !CoreConstants.CONFIG.wsOverrides[method]) {
484+
return data;
485+
}
486+
487+
return CoreJsonPatch.applyPatches(data, CoreConstants.CONFIG.wsOverrides[method]);
488+
}
489+
455490
}
456491

457492
/**

src/core/features/login/pages/email-signup/email-signup.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Component, ElementRef, OnInit, ChangeDetectorRef, inject, viewChild } f
1616
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
1717
import { CoreText } from '@singletons/text';
1818
import { CoreCountries, CoreCountry } from '@singletons/countries';
19-
import { CoreWS, CoreWSExternalWarning } from '@services/ws';
19+
import { CoreWSExternalWarning } from '@services/ws';
2020
import { Translate } from '@singletons';
2121
import { CoreSitePublicConfigResponse, CoreUnauthenticatedSite } from '@classes/sites/unauthenticated-site';
2222
import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate';
@@ -197,10 +197,8 @@ export default class CoreLoginEmailSignupPage implements OnInit {
197197
if (this.ageDigitalConsentVerification === undefined) {
198198

199199
const result = await CorePromiseUtils.ignoreErrors(
200-
CoreWS.callAjax<IsAgeVerificationEnabledWSResponse>(
200+
this.site.callAjax<IsAgeVerificationEnabledWSResponse>(
201201
'core_auth_is_age_digital_consent_verification_enabled',
202-
{},
203-
{ siteUrl: this.site.getURL() },
204202
),
205203
);
206204

@@ -344,10 +342,9 @@ export default class CoreLoginEmailSignupPage implements OnInit {
344342
this.signupForm.value,
345343
);
346344

347-
const result = await CoreWS.callAjax<SignupUserWSResult>(
345+
const result = await this.site.callAjax<SignupUserWSResult>(
348346
'auth_email_signup_user',
349347
params,
350-
{ siteUrl: this.site.getURL() },
351348
);
352349

353350
if (result.success) {
@@ -430,7 +427,7 @@ export default class CoreLoginEmailSignupPage implements OnInit {
430427
params.age = parseInt(params.age, 10); // Use just the integer part.
431428

432429
try {
433-
const result = await CoreWS.callAjax<IsMinorWSResult>('core_auth_is_minor', params, { siteUrl: this.site.getURL() });
430+
const result = await this.site.callAjax<IsMinorWSResult>('core_auth_is_minor', params);
434431

435432
CoreForms.triggerFormSubmittedEvent(this.ageFormElement(), true);
436433

src/core/features/login/services/login-helper.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { CoreApp, CoreStoreConfig } from '@services/app';
2020
import { CoreConfig } from '@services/config';
2121
import { CoreEvents, CoreEventSessionExpiredData, CoreEventSiteData } from '@singletons/events';
2222
import { CoreSites, CoreLoginSiteInfo, CoreSiteBasicInfo } from '@services/sites';
23-
import { CoreWS, CoreWSExternalWarning } from '@services/ws';
23+
import { CoreWSExternalWarning } from '@services/ws';
2424
import { CoreText, CoreTextFormat } from '@singletons/text';
2525
import { CoreObject } from '@singletons/object';
2626
import { CoreConstants } from '@/core/constants';
@@ -65,6 +65,7 @@ import { CorePromiseUtils } from '@singletons/promise-utils';
6565
import { CoreOpener } from '@singletons/opener';
6666
import { CoreAlerts } from '@services/overlays/alerts';
6767
import { CorePrompts } from '@services/overlays/prompts';
68+
import { CoreSitesFactory } from '@services/sites-factory';
6869

6970
/**
7071
* Helper provider that provides some common features regarding authentication.
@@ -271,7 +272,7 @@ export class CoreLoginHelperProvider {
271272
* @returns Signup settings.
272273
*/
273274
async getEmailSignupSettings(siteUrl: string): Promise<AuthEmailSignupSettings> {
274-
return await CoreWS.callAjax('auth_email_get_signup_settings', {}, { siteUrl });
275+
return await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('auth_email_get_signup_settings');
275276
}
276277

277278
/**
@@ -830,7 +831,7 @@ export class CoreLoginHelperProvider {
830831
params.email = email.trim().toLowerCase();
831832
}
832833

833-
return CoreWS.callAjax('core_auth_request_password_reset', params, { siteUrl });
834+
return CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('core_auth_request_password_reset', params);
834835
}
835836

836837
/**
@@ -1038,13 +1039,11 @@ export class CoreLoginHelperProvider {
10381039
// Call the WS to resend the confirmation email.
10391040
const modal = await CoreLoadings.show('core.sending', true);
10401041
const data = { username: username?.toLowerCase(), password };
1041-
const preSets = { siteUrl };
10421042

10431043
try {
1044-
const result = <ResendConfirmationEmailResult> await CoreWS.callAjax(
1044+
const result = <ResendConfirmationEmailResult> await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax(
10451045
'core_auth_resend_confirmation_email',
10461046
data,
1047-
preSets,
10481047
);
10491048

10501049
if (!result.status) {
@@ -1077,7 +1076,7 @@ export class CoreLoginHelperProvider {
10771076
// We don't have site info before login, the only way to check if the WS is available is by calling it.
10781077
try {
10791078
// This call will always fail because we aren't sending parameters.
1080-
await CoreWS.callAjax('core_auth_resend_confirmation_email', {}, { siteUrl });
1079+
await CoreSitesFactory.makeUnauthenticatedSite(siteUrl).callAjax('core_auth_resend_confirmation_email');
10811080

10821081
return true; // We should never reach here.
10831082
} catch (error) {

src/core/services/ws.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export class CoreWSProvider {
132132
*
133133
* @param method The WebService method to be called.
134134
* @param data Arguments to pass to the method.
135-
* @param preSets Extra settings and information. Only some
135+
* @param preSets Extra settings and information.
136136
* @returns Promise resolved with the response data in success and rejected with CoreAjaxError.
137137
*/
138138
callAjax<T = unknown>(method: string, data: Record<string, unknown>, preSets: CoreWSAjaxPreSets): Promise<T> {

src/core/singletons/json-patch.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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

Comments
 (0)