@vidyano-labs/virtual-service 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -1
- package/index.d.ts +28 -2
- package/index.js +69 -24
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -396,6 +396,115 @@ service.registerPersistentObject({
|
|
|
396
396
|
- Return nothing (or undefined) if validation passes
|
|
397
397
|
- Cannot override built-in rules
|
|
398
398
|
|
|
399
|
+
### Translating Validation Messages
|
|
400
|
+
|
|
401
|
+
Provide custom translations for validation error messages to support multiple languages:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
import { VirtualService } from "@vidyano-labs/virtual-service";
|
|
405
|
+
|
|
406
|
+
// Define your translations
|
|
407
|
+
const translations: Record<string, string> = {
|
|
408
|
+
"Required": "Dit veld is verplicht",
|
|
409
|
+
"NotEmpty": "Dit veld mag niet leeg zijn",
|
|
410
|
+
"IsEmail": "E-mailformaat is ongeldig",
|
|
411
|
+
"MaxLength": "Maximale lengte is {0} tekens",
|
|
412
|
+
"MinLength": "Minimale lengte is {0} tekens",
|
|
413
|
+
"MaxValue": "Maximale waarde is {0}",
|
|
414
|
+
"MinValue": "Minimale waarde is {0}"
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Create service and register translation function
|
|
418
|
+
const service = new VirtualService();
|
|
419
|
+
service.registerMessageTranslator((key: string, ...params: any[]) => {
|
|
420
|
+
const template = translations[key] || key;
|
|
421
|
+
// Use String.format from @vidyano/core for {0}, {1} placeholders
|
|
422
|
+
return String.format(template, ...params);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
service.registerPersistentObject({
|
|
426
|
+
type: "Person",
|
|
427
|
+
attributes: [
|
|
428
|
+
{
|
|
429
|
+
name: "Email",
|
|
430
|
+
type: "String",
|
|
431
|
+
rules: "Required; MaxLength(100); IsEmail"
|
|
432
|
+
}
|
|
433
|
+
]
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await service.initialize();
|
|
437
|
+
|
|
438
|
+
const person = await service.getPersistentObject(null, "Person", null, true);
|
|
439
|
+
await person.save();
|
|
440
|
+
|
|
441
|
+
// Error message is now translated
|
|
442
|
+
console.log(person.getAttribute("Email").validationError);
|
|
443
|
+
// "Dit veld is verplicht"
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Translation function signature:**
|
|
447
|
+
```typescript
|
|
448
|
+
type TranslateFunction = (key: string, ...params: any[]) => string;
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**Parameters:**
|
|
452
|
+
- `key` - The validation rule name (e.g., "Required", "MaxLength")
|
|
453
|
+
- `params` - Positional parameters for the message (e.g., max length value)
|
|
454
|
+
|
|
455
|
+
**How it works:**
|
|
456
|
+
1. Built-in validators call `translate(key, ...params)` instead of using hardcoded messages
|
|
457
|
+
2. Your translate function receives the rule name and any parameters
|
|
458
|
+
3. Return the translated message with parameters interpolated
|
|
459
|
+
4. If no translate function is provided, default English messages are used
|
|
460
|
+
|
|
461
|
+
**Custom rules can also use translation:**
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
const service = new VirtualService();
|
|
465
|
+
service.registerMessageTranslator((key: string, ...params: any[]) => {
|
|
466
|
+
const translations: Record<string, string> = {
|
|
467
|
+
"MinimumAge": "L'âge minimum est {0}",
|
|
468
|
+
"MatchesPassword": "Les mots de passe ne correspondent pas"
|
|
469
|
+
};
|
|
470
|
+
const template = translations[key] || key;
|
|
471
|
+
return String.format(template, ...params);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Custom rule using translation
|
|
475
|
+
service.registerBusinessRule("MinimumAge", (value: any, context: RuleValidationContext, minAge: number) => {
|
|
476
|
+
if (!value)
|
|
477
|
+
return;
|
|
478
|
+
|
|
479
|
+
const age = Number(value);
|
|
480
|
+
if (age < minAge)
|
|
481
|
+
throw new Error(context.translate("MinimumAge", minAge));
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Custom rule can still throw direct errors (backward compatible)
|
|
485
|
+
service.registerBusinessRule("IsPhoneNumber", (value: any, _context: RuleValidationContext) => {
|
|
486
|
+
if (!value)
|
|
487
|
+
return;
|
|
488
|
+
|
|
489
|
+
const phoneRegex = /^\+?[\d\s-()]+$/;
|
|
490
|
+
if (!phoneRegex.test(String(value)))
|
|
491
|
+
throw new Error("Invalid phone number format"); // Not translated
|
|
492
|
+
});
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
**Built-in rule keys:**
|
|
496
|
+
- `Required` - Field is required (no params)
|
|
497
|
+
- `NotEmpty` - Field cannot be empty (no params)
|
|
498
|
+
- `IsEmail` - Invalid email format (no params)
|
|
499
|
+
- `IsUrl` - Invalid URL format (no params)
|
|
500
|
+
- `MaxLength` - Maximum length exceeded (param: max length)
|
|
501
|
+
- `MinLength` - Minimum length not met (param: min length)
|
|
502
|
+
- `MaxValue` - Maximum value exceeded (param: max value)
|
|
503
|
+
- `MinValue` - Minimum value not met (param: min value)
|
|
504
|
+
- `IsBase64` - Invalid base64 string (no params)
|
|
505
|
+
- `IsRegex` - Invalid regex pattern (no params)
|
|
506
|
+
- `IsWord` - Invalid word characters (no params)
|
|
507
|
+
|
|
399
508
|
## Queries
|
|
400
509
|
|
|
401
510
|
### Basic Query Registration
|
|
@@ -1091,6 +1200,7 @@ test("search and sort query results", async () => {
|
|
|
1091
1200
|
| `registerAction(config)` | Register a custom action |
|
|
1092
1201
|
| `registerBusinessRule(name, validator)` | Register a validation rule |
|
|
1093
1202
|
| `registerPersistentObjectActions(type, Class)` | Register lifecycle handlers |
|
|
1203
|
+
| `registerMessageTranslator(translateFn)` | Register message translator for system messages |
|
|
1094
1204
|
| `initialize()` | Finalize registrations |
|
|
1095
1205
|
|
|
1096
1206
|
### VirtualPersistentObjectActions
|
|
@@ -1131,7 +1241,9 @@ import type {
|
|
|
1131
1241
|
ActionArgs,
|
|
1132
1242
|
ActionContext,
|
|
1133
1243
|
RuleValidatorFn,
|
|
1134
|
-
RuleValidationContext
|
|
1244
|
+
RuleValidationContext,
|
|
1245
|
+
TranslateFunction,
|
|
1246
|
+
TranslateFunction
|
|
1135
1247
|
} from "@vidyano-labs/virtual-service";
|
|
1136
1248
|
```
|
|
1137
1249
|
|
package/index.d.ts
CHANGED
|
@@ -233,6 +233,17 @@ type ActionContext = {
|
|
|
233
233
|
*/
|
|
234
234
|
setNotification: (message: string, type: Dto.NotificationType, duration?: number) => void;
|
|
235
235
|
};
|
|
236
|
+
/**
|
|
237
|
+
* Translation function for system messages
|
|
238
|
+
* @param key - The message key (e.g., "Required", "MaxLength")
|
|
239
|
+
* @param params - Positional parameters for the message
|
|
240
|
+
* @returns Translated message with parameters interpolated
|
|
241
|
+
*
|
|
242
|
+
* Example:
|
|
243
|
+
* translate("MaxLength", 50) => "Maximum length is 50 characters"
|
|
244
|
+
* translate("MinValue", 18) => "Minimum value is 18"
|
|
245
|
+
*/
|
|
246
|
+
type TranslateFunction = (key: string, ...params: any[]) => string;
|
|
236
247
|
/**
|
|
237
248
|
* Context provided to business rule validators for accessing the persistent object
|
|
238
249
|
*/
|
|
@@ -245,6 +256,10 @@ type RuleValidationContext = {
|
|
|
245
256
|
* The attribute being validated (wrapped with helper methods)
|
|
246
257
|
*/
|
|
247
258
|
attribute: VirtualPersistentObjectAttribute;
|
|
259
|
+
/**
|
|
260
|
+
* Translation function for system messages
|
|
261
|
+
*/
|
|
262
|
+
translate: TranslateFunction;
|
|
248
263
|
};
|
|
249
264
|
/**
|
|
250
265
|
* PersistentObject configuration - converted to PersistentObjectDto
|
|
@@ -460,6 +475,10 @@ declare class VirtualPersistentObjectActions {
|
|
|
460
475
|
declare class VirtualServiceHooks extends ServiceHooks {
|
|
461
476
|
#private;
|
|
462
477
|
constructor();
|
|
478
|
+
/**
|
|
479
|
+
* Sets the translation function for system messages
|
|
480
|
+
*/
|
|
481
|
+
setTranslate(translate: TranslateFunction): void;
|
|
463
482
|
/**
|
|
464
483
|
* Intercepts fetch requests and routes them to virtual handlers
|
|
465
484
|
*/
|
|
@@ -512,7 +531,7 @@ declare class VirtualService extends Service {
|
|
|
512
531
|
#private;
|
|
513
532
|
/**
|
|
514
533
|
* Creates a new VirtualService instance.
|
|
515
|
-
* @param hooks - Optional custom
|
|
534
|
+
* @param hooks - Optional custom hooks instance.
|
|
516
535
|
*/
|
|
517
536
|
constructor(hooks?: VirtualServiceHooks);
|
|
518
537
|
/**
|
|
@@ -562,7 +581,14 @@ declare class VirtualService extends Service {
|
|
|
562
581
|
* @throws Error if called after initialize().
|
|
563
582
|
*/
|
|
564
583
|
registerPersistentObjectActions(type: string, ActionsClass: typeof VirtualPersistentObjectActions): void;
|
|
584
|
+
/**
|
|
585
|
+
* Registers a message translator for translating system messages.
|
|
586
|
+
* Must be called before initialize().
|
|
587
|
+
* @param translate - The translation function.
|
|
588
|
+
* @throws Error if called after initialize().
|
|
589
|
+
*/
|
|
590
|
+
registerMessageTranslator(translate: TranslateFunction): void;
|
|
565
591
|
}
|
|
566
592
|
|
|
567
593
|
export { VirtualPersistentObjectActions, VirtualService, VirtualServiceHooks };
|
|
568
|
-
export type { ActionArgs, ActionConfig, ActionContext, ActionHandler, RuleValidationContext, RuleValidatorFn, VirtualPersistentObject, VirtualPersistentObjectAttribute, VirtualPersistentObjectAttributeConfig, VirtualPersistentObjectConfig, VirtualQueryConfig, VirtualQueryExecuteResult };
|
|
594
|
+
export type { ActionArgs, ActionConfig, ActionContext, ActionHandler, RuleValidationContext, RuleValidatorFn, TranslateFunction, VirtualPersistentObject, VirtualPersistentObjectAttribute, VirtualPersistentObjectAttributeConfig, VirtualPersistentObjectConfig, VirtualQueryConfig, VirtualQueryExecuteResult };
|
package/index.js
CHANGED
|
@@ -1228,7 +1228,9 @@ class VirtualPersistentObjectActionsRegistry {
|
|
|
1228
1228
|
class BusinessRuleValidator {
|
|
1229
1229
|
#builtInRules = new Map();
|
|
1230
1230
|
#customRules = new Map();
|
|
1231
|
+
#translate;
|
|
1231
1232
|
constructor() {
|
|
1233
|
+
this.#translate = this.#defaultTranslate;
|
|
1232
1234
|
// Register all built-in rules
|
|
1233
1235
|
this.#builtInRules.set("IsBase64", this.#validateIsBase64.bind(this));
|
|
1234
1236
|
this.#builtInRules.set("IsEmail", this.#validateIsEmail.bind(this));
|
|
@@ -1242,6 +1244,32 @@ class BusinessRuleValidator {
|
|
|
1242
1244
|
this.#builtInRules.set("NotEmpty", this.#validateNotEmpty.bind(this));
|
|
1243
1245
|
this.#builtInRules.set("Required", this.#validateRequired.bind(this));
|
|
1244
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Sets the translation function
|
|
1249
|
+
*/
|
|
1250
|
+
setTranslate(translate) {
|
|
1251
|
+
this.#translate = translate;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Default English translations - used when no translate function provided
|
|
1255
|
+
*/
|
|
1256
|
+
#defaultTranslate(key, ...params) {
|
|
1257
|
+
const defaults = {
|
|
1258
|
+
"Required": () => "This field is required",
|
|
1259
|
+
"NotEmpty": () => "This field cannot be empty",
|
|
1260
|
+
"IsEmail": () => "Email format is invalid",
|
|
1261
|
+
"IsUrl": () => "Value must be a valid URL",
|
|
1262
|
+
"MaxLength": (max) => `Maximum length is ${max} characters`,
|
|
1263
|
+
"MinLength": (min) => `Minimum length is ${min} characters`,
|
|
1264
|
+
"MaxValue": (max) => `Maximum value is ${max}`,
|
|
1265
|
+
"MinValue": (min) => `Minimum value is ${min}`,
|
|
1266
|
+
"IsBase64": () => "Value must be a valid base64 string",
|
|
1267
|
+
"IsRegex": () => "Value must be a valid regular expression",
|
|
1268
|
+
"IsWord": () => "Value must contain only word characters"
|
|
1269
|
+
};
|
|
1270
|
+
const translator = defaults[key];
|
|
1271
|
+
return translator ? translator(...params) : key;
|
|
1272
|
+
}
|
|
1245
1273
|
/**
|
|
1246
1274
|
* Register a custom business rule
|
|
1247
1275
|
* @throws Error if attempting to override a built-in rule
|
|
@@ -1272,7 +1300,8 @@ class BusinessRuleValidator {
|
|
|
1272
1300
|
// Create validation context
|
|
1273
1301
|
const context = {
|
|
1274
1302
|
persistentObject: wrappedPo,
|
|
1275
|
-
attribute: wrappedAttr
|
|
1303
|
+
attribute: wrappedAttr,
|
|
1304
|
+
translate: this.#translate
|
|
1276
1305
|
};
|
|
1277
1306
|
// Get the converted value (e.g., boolean from "True"/"False", number from string)
|
|
1278
1307
|
const convertedValue = this.#getConvertedValue(attr);
|
|
@@ -1345,87 +1374,87 @@ class BusinessRuleValidator {
|
|
|
1345
1374
|
return fromServiceValue(attr.value, attr.type);
|
|
1346
1375
|
}
|
|
1347
1376
|
// Built-in validators - throw errors instead of returning strings
|
|
1348
|
-
#validateIsBase64(value,
|
|
1377
|
+
#validateIsBase64(value, _context) {
|
|
1349
1378
|
if (value == null || value === "")
|
|
1350
1379
|
return;
|
|
1351
1380
|
const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1352
1381
|
if (!base64Regex.test(String(value)))
|
|
1353
|
-
throw new Error("
|
|
1382
|
+
throw new Error(this.#translate("IsBase64"));
|
|
1354
1383
|
}
|
|
1355
|
-
#validateIsEmail(value,
|
|
1384
|
+
#validateIsEmail(value, _context) {
|
|
1356
1385
|
if (value == null || value === "")
|
|
1357
1386
|
return;
|
|
1358
1387
|
// Only allow ASCII characters in email addresses
|
|
1359
1388
|
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
1360
1389
|
if (!emailRegex.test(String(value)))
|
|
1361
|
-
throw new Error("
|
|
1390
|
+
throw new Error(this.#translate("IsEmail"));
|
|
1362
1391
|
}
|
|
1363
|
-
#validateIsRegex(value,
|
|
1392
|
+
#validateIsRegex(value, _context) {
|
|
1364
1393
|
if (value == null || value === "")
|
|
1365
1394
|
return;
|
|
1366
1395
|
try {
|
|
1367
1396
|
new RegExp(String(value));
|
|
1368
1397
|
}
|
|
1369
1398
|
catch {
|
|
1370
|
-
throw new Error("
|
|
1399
|
+
throw new Error(this.#translate("IsRegex"));
|
|
1371
1400
|
}
|
|
1372
1401
|
}
|
|
1373
|
-
#validateIsUrl(value,
|
|
1402
|
+
#validateIsUrl(value, _context) {
|
|
1374
1403
|
if (value == null || value === "")
|
|
1375
1404
|
return;
|
|
1376
1405
|
try {
|
|
1377
1406
|
new URL(String(value));
|
|
1378
1407
|
}
|
|
1379
1408
|
catch {
|
|
1380
|
-
throw new Error("
|
|
1409
|
+
throw new Error(this.#translate("IsUrl"));
|
|
1381
1410
|
}
|
|
1382
1411
|
}
|
|
1383
|
-
#validateIsWord(value,
|
|
1412
|
+
#validateIsWord(value, _context) {
|
|
1384
1413
|
if (value == null || value === "")
|
|
1385
1414
|
return;
|
|
1386
1415
|
const wordRegex = /^\w+$/;
|
|
1387
1416
|
if (!wordRegex.test(String(value)))
|
|
1388
|
-
throw new Error("
|
|
1417
|
+
throw new Error(this.#translate("IsWord"));
|
|
1389
1418
|
}
|
|
1390
|
-
#validateMaxLength(value,
|
|
1419
|
+
#validateMaxLength(value, _context, maxLength) {
|
|
1391
1420
|
if (value == null || value === "")
|
|
1392
1421
|
return;
|
|
1393
1422
|
const length = String(value).length;
|
|
1394
1423
|
if (length > maxLength)
|
|
1395
|
-
throw new Error(
|
|
1424
|
+
throw new Error(this.#translate("MaxLength", maxLength));
|
|
1396
1425
|
}
|
|
1397
|
-
#validateMaxValue(value,
|
|
1426
|
+
#validateMaxValue(value, _context, maximum) {
|
|
1398
1427
|
if (value == null || value === "")
|
|
1399
1428
|
return;
|
|
1400
1429
|
const num = Number(value);
|
|
1401
1430
|
if (isNaN(num))
|
|
1402
1431
|
throw new Error("Value must be a number");
|
|
1403
1432
|
if (num > maximum)
|
|
1404
|
-
throw new Error(
|
|
1433
|
+
throw new Error(this.#translate("MaxValue", maximum));
|
|
1405
1434
|
}
|
|
1406
|
-
#validateMinLength(value,
|
|
1435
|
+
#validateMinLength(value, _context, minLength) {
|
|
1407
1436
|
if (value == null || value === "")
|
|
1408
1437
|
return;
|
|
1409
1438
|
const length = String(value).length;
|
|
1410
1439
|
if (length < minLength)
|
|
1411
|
-
throw new Error(
|
|
1440
|
+
throw new Error(this.#translate("MinLength", minLength));
|
|
1412
1441
|
}
|
|
1413
|
-
#validateMinValue(value,
|
|
1442
|
+
#validateMinValue(value, _context, minimum) {
|
|
1414
1443
|
if (value == null || value === "")
|
|
1415
1444
|
return;
|
|
1416
1445
|
const num = Number(value);
|
|
1417
1446
|
if (isNaN(num))
|
|
1418
1447
|
throw new Error("Value must be a number");
|
|
1419
1448
|
if (num < minimum)
|
|
1420
|
-
throw new Error(
|
|
1449
|
+
throw new Error(this.#translate("MinValue", minimum));
|
|
1421
1450
|
}
|
|
1422
|
-
#validateRequired(value,
|
|
1451
|
+
#validateRequired(value, _context) {
|
|
1423
1452
|
if (value == null)
|
|
1424
|
-
throw new Error("
|
|
1453
|
+
throw new Error(this.#translate("Required"));
|
|
1425
1454
|
}
|
|
1426
|
-
#validateNotEmpty(value,
|
|
1455
|
+
#validateNotEmpty(value, _context) {
|
|
1427
1456
|
if (value == null || value === "" || (typeof value === "string" && value.trim() === ""))
|
|
1428
|
-
throw new Error("
|
|
1457
|
+
throw new Error(this.#translate("NotEmpty"));
|
|
1429
1458
|
}
|
|
1430
1459
|
}
|
|
1431
1460
|
|
|
@@ -1461,6 +1490,12 @@ class VirtualServiceHooks extends ServiceHooks {
|
|
|
1461
1490
|
this.#actionDefinitions.set("Save", { name: "Save", displayName: "Save", isPinned: false });
|
|
1462
1491
|
this.#actionDefinitions.set("SelectReference", { name: "SelectReference", displayName: "Select", isPinned: false });
|
|
1463
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Sets the translation function for system messages
|
|
1495
|
+
*/
|
|
1496
|
+
setTranslate(translate) {
|
|
1497
|
+
this.#validator.setTranslate(translate);
|
|
1498
|
+
}
|
|
1464
1499
|
/**
|
|
1465
1500
|
* Intercepts fetch requests and routes them to virtual handlers
|
|
1466
1501
|
*/
|
|
@@ -1969,7 +2004,7 @@ class VirtualService extends Service {
|
|
|
1969
2004
|
#isInitialized = false;
|
|
1970
2005
|
/**
|
|
1971
2006
|
* Creates a new VirtualService instance.
|
|
1972
|
-
* @param hooks - Optional custom
|
|
2007
|
+
* @param hooks - Optional custom hooks instance.
|
|
1973
2008
|
*/
|
|
1974
2009
|
constructor(hooks) {
|
|
1975
2010
|
super("http://virtual.local", hooks ?? new VirtualServiceHooks(), true);
|
|
@@ -2036,6 +2071,16 @@ class VirtualService extends Service {
|
|
|
2036
2071
|
this.#ensureNotInitialized();
|
|
2037
2072
|
this.virtualHooks.registerPersistentObjectActions(type, ActionsClass);
|
|
2038
2073
|
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Registers a message translator for translating system messages.
|
|
2076
|
+
* Must be called before initialize().
|
|
2077
|
+
* @param translate - The translation function.
|
|
2078
|
+
* @throws Error if called after initialize().
|
|
2079
|
+
*/
|
|
2080
|
+
registerMessageTranslator(translate) {
|
|
2081
|
+
this.#ensureNotInitialized();
|
|
2082
|
+
this.virtualHooks.setTranslate(translate);
|
|
2083
|
+
}
|
|
2039
2084
|
/**
|
|
2040
2085
|
* Throws an error if the service has already been initialized.
|
|
2041
2086
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vidyano-labs/virtual-service",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Virtual service implementation for testing Vidyano applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -20,5 +20,5 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
-
"gitHash": "
|
|
23
|
+
"gitHash": "db4cd7380e2b729aedf28a81ab348823aa338c4b"
|
|
24
24
|
}
|