Skip to content

Commit cded259

Browse files
authored
feat(input_schema): Custom error messages (#567)
Solves apify/apify-core#24045 - implements option to define custom error messages within the input schema based on the specification here: #556 Few notes to the implementation: 1. It supports only property level validation keywords like `type`, `minLength`, `pattern`,.. but no schema/object level keywords (`required`, `additionalProperties`) 2. It supports our custom validation keywords too (`patternKey` and `patternValue`) 3. This allows to specify any validation keyword in any property. There is no check whether the property actually supports or moreover has defined the keyword. This makes the implementation much more simpler using one `errorMessage` definition and reusing it in all the properties. 4. This would start working out-of-the box in the UI, but we use there custom function `beautifyValidationMessage` to modify the default error message for UI usecases and the custom message would just bubble through without any modification in most cases (there is regex check) - IMHO expected behavior, but mentioning it here to be sure.
1 parent 7e17544 commit cded259

File tree

5 files changed

+364
-28
lines changed

5 files changed

+364
-28
lines changed

packages/input_schema/src/input_schema.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,48 @@ const [fieldDefinitions, subFieldDefinitions] = Object
4040
return acc;
4141
}, [[], []]);
4242

43+
/**
44+
* Retrieves a custom error message defined in the schema for a particular schema path.
45+
* @param rootSchema json schema object
46+
* @param schemaPath schema path to the failed validation keyword,
47+
* as provided in an AJV error object, including the keyword at the end, e.g. "#/properties/name/type"
48+
*/
49+
export function getCustomErrorMessage(rootSchema: Record<string, any>, schemaPath: string): string | null {
50+
if (!schemaPath) return null;
51+
52+
const pathParts = schemaPath
53+
.replace(/^#\//, '')
54+
.split('/')
55+
.filter(Boolean);
56+
57+
// The last part is the keyword
58+
const keyword = pathParts.pop();
59+
if (!keyword) return null;
60+
61+
// Navigate through the schema to find the relevant fragment
62+
let schemaFragment: Record<string, any> = rootSchema;
63+
for (const key of pathParts) {
64+
if (schemaFragment && typeof schemaFragment === 'object') {
65+
schemaFragment = schemaFragment[key];
66+
} else {
67+
return null;
68+
}
69+
}
70+
71+
if (typeof schemaFragment !== 'object') {
72+
return null;
73+
}
74+
75+
const { errorMessage } = schemaFragment;
76+
if (!errorMessage) return null;
77+
78+
if (typeof errorMessage === 'object' && keyword in errorMessage) {
79+
return errorMessage[keyword];
80+
}
81+
82+
return null;
83+
}
84+
4385
/**
4486
* This function parses AJV error and transforms it into a readable string.
4587
*
@@ -68,6 +110,14 @@ export function parseAjvError(
68110
return name.replace(/^\/|\/$/g, '').replace(/\//g, '.');
69111
};
70112

113+
// First, try to get a custom error message from the schema
114+
// If found, use it directly and skip further processing
115+
const customError = getCustomErrorMessage({ properties }, error.schemaPath);
116+
if (customError) {
117+
fieldKey = cleanPropertyName(error.instancePath);
118+
return { fieldKey, message: customError };
119+
}
120+
71121
// If error is with keyword type, it means that type of input is incorrect
72122
// this can mean that provided value is null
73123
if (error.keyword === 'type') {

packages/input_schema/src/utilities.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { countries } from 'countries-list';
66
import { PROXY_URL_REGEX, URL_REGEX } from '@apify/consts';
77
import { isEncryptedValueForFieldSchema, isEncryptedValueForFieldType } from '@apify/input_secrets';
88

9-
import { parseAjvError } from './input_schema';
9+
import { getCustomErrorMessage, parseAjvError } from './input_schema';
1010
import { m } from './intl';
1111

1212
/**
@@ -197,7 +197,8 @@ export function validateInputUsingValidator(
197197
if (!check.test(item.key)) invalidIndexes.push(index);
198198
});
199199
if (invalidIndexes.length) {
200-
fieldErrors.push(m('inputSchema.validation.arrayKeysInvalid', {
200+
const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternKey`);
201+
fieldErrors.push(customError ?? m('inputSchema.validation.arrayKeysInvalid', {
201202
rootName: 'input',
202203
fieldKey: property,
203204
invalidIndexes: invalidIndexes.join(','),
@@ -213,7 +214,8 @@ export function validateInputUsingValidator(
213214
if (!check.test(item.value)) invalidIndexes.push(index);
214215
});
215216
if (invalidIndexes.length) {
216-
fieldErrors.push(m('inputSchema.validation.arrayValuesInvalid', {
217+
const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`);
218+
fieldErrors.push(customError ?? m('inputSchema.validation.arrayValuesInvalid', {
217219
rootName: 'input',
218220
fieldKey: property,
219221
invalidIndexes: invalidIndexes.join(','),
@@ -228,7 +230,8 @@ export function validateInputUsingValidator(
228230
if (!check.test(item)) invalidIndexes.push(index);
229231
});
230232
if (invalidIndexes.length) {
231-
fieldErrors.push(m('inputSchema.validation.arrayValuesInvalid', {
233+
const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`);
234+
fieldErrors.push(customError ?? m('inputSchema.validation.arrayValuesInvalid', {
232235
rootName: 'input',
233236
fieldKey: property,
234237
invalidIndexes: invalidIndexes.join(','),
@@ -246,7 +249,8 @@ export function validateInputUsingValidator(
246249
if (!check.test(key)) invalidKeys.push(key);
247250
});
248251
if (invalidKeys.length) {
249-
fieldErrors.push(m('inputSchema.validation.objectKeysInvalid', {
252+
const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternKey`);
253+
fieldErrors.push(customError ?? m('inputSchema.validation.objectKeysInvalid', {
250254
rootName: 'input',
251255
fieldKey: property,
252256
invalidKeys: invalidKeys.join(','),
@@ -262,7 +266,8 @@ export function validateInputUsingValidator(
262266
if (typeof propertyValue !== 'string' || !check.test(propertyValue)) invalidKeys.push(key);
263267
});
264268
if (invalidKeys.length) {
265-
fieldErrors.push(m('inputSchema.validation.objectValuesInvalid', {
269+
const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`);
270+
fieldErrors.push(customError ?? m('inputSchema.validation.objectValuesInvalid', {
266271
rootName: 'input',
267272
fieldKey: property,
268273
invalidKeys: invalidKeys.join(','),

packages/json_schemas/schemas/input.schema.json

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@
7676
"type": "array",
7777
"items": { "type": "string" },
7878
"minItems": 1
79-
}
79+
},
80+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
8081
},
8182
"required": ["type", "title", "description", "enum"],
8283
"if": {
@@ -120,7 +121,8 @@
120121
"maxLength": { "type": "integer" },
121122
"sectionCaption": { "type": "string" },
122123
"sectionDescription": { "type": "string" },
123-
"isSecret": { "enum": [false] }
124+
"isSecret": { "enum": [false] },
125+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
124126
},
125127
"unevaluatedProperties": false,
126128
"allOf": [
@@ -191,7 +193,8 @@
191193
"editor": { "enum": ["textfield", "textarea", "hidden"] },
192194
"isSecret": { "enum": [true] },
193195
"sectionCaption": { "type": "string" },
194-
"sectionDescription": { "type": "string" }
196+
"sectionDescription": { "type": "string" },
197+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
195198
}
196199
}
197200
},
@@ -226,7 +229,8 @@
226229
"placeholderValue": { "type": "string" },
227230
"patternKey": { "type": "string" },
228231
"patternValue": { "type": "string" },
229-
"isSecret": { "enum": [false] }
232+
"isSecret": { "enum": [false] },
233+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
230234
},
231235
"unevaluatedProperties": false,
232236
"allOf": [
@@ -323,7 +327,8 @@
323327
"sectionCaption": { "type": "string" },
324328
"sectionDescription": { "type": "string" },
325329
"items": { "$ref": "#/definitions/arrayItems" },
326-
"isSecret": { "enum": [true] }
330+
"isSecret": { "enum": [true] },
331+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
327332
}
328333
}
329334
},
@@ -367,7 +372,8 @@
367372
},
368373
"additionalProperties": {
369374
"type": "boolean"
370-
}
375+
},
376+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
371377
},
372378
"unevaluatedProperties": false,
373379
"allOf": [
@@ -422,7 +428,8 @@
422428
},
423429
"additionalProperties": {
424430
"type": "boolean"
425-
}
431+
},
432+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
426433
}
427434
}
428435
},
@@ -442,7 +449,8 @@
442449
"unit": { "type": "string" },
443450
"editor": { "enum": ["number", "hidden"] },
444451
"sectionCaption": { "type": "string" },
445-
"sectionDescription": { "type": "string" }
452+
"sectionDescription": { "type": "string" },
453+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
446454
},
447455
"required": ["type", "title", "description"],
448456
"if": {
@@ -471,7 +479,8 @@
471479
"unit": { "type": "string" },
472480
"editor": { "enum": ["number", "hidden"] },
473481
"sectionCaption": { "type": "string" },
474-
"sectionDescription": { "type": "string" }
482+
"sectionDescription": { "type": "string" },
483+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
475484
},
476485
"required": ["type", "title", "description"],
477486
"if": {
@@ -499,7 +508,8 @@
499508
"groupDescription": { "type": "string" },
500509
"editor": { "enum": ["checkbox", "hidden"] },
501510
"sectionCaption": { "type": "string" },
502-
"sectionDescription": { "type": "string" }
511+
"sectionDescription": { "type": "string" },
512+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
503513
},
504514
"required": ["type", "title", "description"],
505515
"if": {
@@ -541,7 +551,8 @@
541551
"minLength": { "type": "integer" },
542552
"maxLength": { "type": "integer" },
543553
"sectionCaption": { "type": "string" },
544-
"sectionDescription": { "type": "string" }
554+
"sectionDescription": { "type": "string" },
555+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
545556
},
546557
"required": ["type", "title", "description", "resourceType"],
547558
"allOf": [
@@ -600,7 +611,8 @@
600611
"uniqueItems": { "type": "boolean" },
601612
"resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] },
602613
"sectionCaption": { "type": "string" },
603-
"sectionDescription": { "type": "string" }
614+
"sectionDescription": { "type": "string" },
615+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
604616
},
605617
"required": ["type", "title", "description", "resourceType"],
606618
"allOf": [
@@ -652,7 +664,8 @@
652664
"nullable": { "type": "boolean" },
653665
"editor": { "enum": ["json", "hidden"] },
654666
"sectionCaption": { "type": "string" },
655-
"sectionDescription": { "type": "string" }
667+
"sectionDescription": { "type": "string" },
668+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
656669
},
657670
"required": ["type", "title", "description", "editor"],
658671
"if": {
@@ -687,7 +700,8 @@
687700
"type": "array",
688701
"items": { "type": "string" },
689702
"minItems": 1
690-
}
703+
},
704+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
691705
},
692706
"required": ["type", "title", "description", "enum"],
693707
"if": {
@@ -714,7 +728,8 @@
714728
"nullable": { "type": "boolean" },
715729
"minLength": { "type": "integer" },
716730
"maxLength": { "type": "integer" },
717-
"editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "select", "fileupload", "hidden"] }
731+
"editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "select", "fileupload", "hidden"] },
732+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
718733
},
719734
"required": ["type", "title", "description"],
720735
"allOf": [
@@ -790,7 +805,8 @@
790805
"placeholderKey": { "type": "string" },
791806
"placeholderValue": { "type": "string" },
792807
"patternKey": { "type": "string" },
793-
"patternValue": { "type": "string" }
808+
"patternValue": { "type": "string" },
809+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
794810
},
795811
"required": ["type", "title", "description"],
796812
"unevaluatedProperties": false,
@@ -886,7 +902,8 @@
886902
"minimum": { "type": "integer" },
887903
"maximum": { "type": "integer" },
888904
"unit": { "type": "string" },
889-
"editor": { "enum": ["number", "hidden"] }
905+
"editor": { "enum": ["number", "hidden"] },
906+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
890907
},
891908
"required": ["type", "title", "description"],
892909
"if": {
@@ -913,7 +930,8 @@
913930
"minimum": { "type": "number" },
914931
"maximum": { "type": "number" },
915932
"unit": { "type": "string" },
916-
"editor": { "enum": ["number", "hidden"] }
933+
"editor": { "enum": ["number", "hidden"] },
934+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
917935
},
918936
"required": ["type", "title", "description"],
919937
"if": {
@@ -939,7 +957,8 @@
939957
"nullable": { "type": "boolean" },
940958
"groupCaption": { "type": "string" },
941959
"groupDescription": { "type": "string" },
942-
"editor": { "enum": ["checkbox", "hidden"] }
960+
"editor": { "enum": ["checkbox", "hidden"] },
961+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
943962
},
944963
"required": ["type", "title", "description"],
945964
"if": {
@@ -977,7 +996,8 @@
977996
},
978997
"additionalProperties": {
979998
"type": "boolean"
980-
}
999+
},
1000+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
9811001
},
9821002
"required": ["type", "title", "description"],
9831003
"if": {
@@ -1017,7 +1037,8 @@
10171037
"nullable": { "type": "boolean" },
10181038
"pattern": { "type": "string" },
10191039
"minLength": { "type": "integer" },
1020-
"maxLength": { "type": "integer" }
1040+
"maxLength": { "type": "integer" },
1041+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
10211042
},
10221043
"required": ["type", "title", "description", "resourceType"],
10231044
"if": {
@@ -1057,7 +1078,8 @@
10571078
"minItems": { "type": "integer" },
10581079
"maxItems": { "type": "integer" },
10591080
"uniqueItems": { "type": "boolean" },
1060-
"resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] }
1081+
"resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] },
1082+
"errorMessage": { "$ref": "#/definitions/errorMessage" }
10611083
},
10621084
"required": ["type", "title", "description", "resourceType"],
10631085
"if": {
@@ -1601,6 +1623,27 @@
16011623
"required": ["type"]
16021624
}
16031625
}
1626+
},
1627+
"errorMessage": {
1628+
"title": "Utils: Custom error message definition",
1629+
"type": "object",
1630+
"properties": {
1631+
"type": { "type": "string" },
1632+
"pattern": { "type": "string" },
1633+
"minLength": { "type": "string" },
1634+
"maxLength": { "type": "string" },
1635+
"enum": { "type": "string" },
1636+
"minimum": { "type": "string" },
1637+
"maximum": { "type": "string" },
1638+
"minItems": { "type": "string" },
1639+
"maxItems": { "type": "string" },
1640+
"uniqueItems": { "type": "string" },
1641+
"minProperties": { "type": "string" },
1642+
"maxProperties": { "type": "string" },
1643+
"patternKey": { "type": "string" },
1644+
"patternValue": { "type": "string" }
1645+
},
1646+
"additionalProperties": false
16041647
}
16051648
}
16061649
}

0 commit comments

Comments
 (0)