diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index 67be6f00..af3f21b7 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -40,6 +40,48 @@ const [fieldDefinitions, subFieldDefinitions] = Object return acc; }, [[], []]); +/** + * Retrieves a custom error message defined in the schema for a particular schema path. + * @param rootSchema json schema object + * @param schemaPath schema path to the failed validation keyword, + * as provided in an AJV error object, including the keyword at the end, e.g. "#/properties/name/type" + */ +export function getCustomErrorMessage(rootSchema: Record, schemaPath: string): string | null { + if (!schemaPath) return null; + + const pathParts = schemaPath + .replace(/^#\//, '') + .split('/') + .filter(Boolean); + + // The last part is the keyword + const keyword = pathParts.pop(); + if (!keyword) return null; + + // Navigate through the schema to find the relevant fragment + let schemaFragment: Record = rootSchema; + for (const key of pathParts) { + if (schemaFragment && typeof schemaFragment === 'object') { + schemaFragment = schemaFragment[key]; + } else { + return null; + } + } + + if (typeof schemaFragment !== 'object') { + return null; + } + + const { errorMessage } = schemaFragment; + if (!errorMessage) return null; + + if (typeof errorMessage === 'object' && keyword in errorMessage) { + return errorMessage[keyword]; + } + + return null; +} + /** * This function parses AJV error and transforms it into a readable string. * @@ -68,6 +110,14 @@ export function parseAjvError( return name.replace(/^\/|\/$/g, '').replace(/\//g, '.'); }; + // First, try to get a custom error message from the schema + // If found, use it directly and skip further processing + const customError = getCustomErrorMessage({ properties }, error.schemaPath); + if (customError) { + fieldKey = cleanPropertyName(error.instancePath); + return { fieldKey, message: customError }; + } + // If error is with keyword type, it means that type of input is incorrect // this can mean that provided value is null if (error.keyword === 'type') { diff --git a/packages/input_schema/src/utilities.ts b/packages/input_schema/src/utilities.ts index ee408060..a4e93a71 100644 --- a/packages/input_schema/src/utilities.ts +++ b/packages/input_schema/src/utilities.ts @@ -6,7 +6,7 @@ import { countries } from 'countries-list'; import { PROXY_URL_REGEX, URL_REGEX } from '@apify/consts'; import { isEncryptedValueForFieldSchema, isEncryptedValueForFieldType } from '@apify/input_secrets'; -import { parseAjvError } from './input_schema'; +import { getCustomErrorMessage, parseAjvError } from './input_schema'; import { m } from './intl'; /** @@ -197,7 +197,8 @@ export function validateInputUsingValidator( if (!check.test(item.key)) invalidIndexes.push(index); }); if (invalidIndexes.length) { - fieldErrors.push(m('inputSchema.validation.arrayKeysInvalid', { + const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternKey`); + fieldErrors.push(customError ?? m('inputSchema.validation.arrayKeysInvalid', { rootName: 'input', fieldKey: property, invalidIndexes: invalidIndexes.join(','), @@ -213,7 +214,8 @@ export function validateInputUsingValidator( if (!check.test(item.value)) invalidIndexes.push(index); }); if (invalidIndexes.length) { - fieldErrors.push(m('inputSchema.validation.arrayValuesInvalid', { + const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`); + fieldErrors.push(customError ?? m('inputSchema.validation.arrayValuesInvalid', { rootName: 'input', fieldKey: property, invalidIndexes: invalidIndexes.join(','), @@ -228,7 +230,8 @@ export function validateInputUsingValidator( if (!check.test(item)) invalidIndexes.push(index); }); if (invalidIndexes.length) { - fieldErrors.push(m('inputSchema.validation.arrayValuesInvalid', { + const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`); + fieldErrors.push(customError ?? m('inputSchema.validation.arrayValuesInvalid', { rootName: 'input', fieldKey: property, invalidIndexes: invalidIndexes.join(','), @@ -246,7 +249,8 @@ export function validateInputUsingValidator( if (!check.test(key)) invalidKeys.push(key); }); if (invalidKeys.length) { - fieldErrors.push(m('inputSchema.validation.objectKeysInvalid', { + const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternKey`); + fieldErrors.push(customError ?? m('inputSchema.validation.objectKeysInvalid', { rootName: 'input', fieldKey: property, invalidKeys: invalidKeys.join(','), @@ -262,7 +266,8 @@ export function validateInputUsingValidator( if (typeof propertyValue !== 'string' || !check.test(propertyValue)) invalidKeys.push(key); }); if (invalidKeys.length) { - fieldErrors.push(m('inputSchema.validation.objectValuesInvalid', { + const customError = getCustomErrorMessage(inputSchema, `properties/${property}/patternValue`); + fieldErrors.push(customError ?? m('inputSchema.validation.objectValuesInvalid', { rootName: 'input', fieldKey: property, invalidKeys: invalidKeys.join(','), diff --git a/packages/json_schemas/schemas/input.schema.json b/packages/json_schemas/schemas/input.schema.json index d649b00e..05dd8986 100644 --- a/packages/json_schemas/schemas/input.schema.json +++ b/packages/json_schemas/schemas/input.schema.json @@ -76,7 +76,8 @@ "type": "array", "items": { "type": "string" }, "minItems": 1 - } + }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "enum"], "if": { @@ -124,7 +125,8 @@ "maxLength": { "type": "integer" }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "isSecret": { "enum": [false] } + "isSecret": { "enum": [false] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "unevaluatedProperties": false, "allOf": [ @@ -171,7 +173,8 @@ "editor": { "enum": ["textfield", "textarea", "hidden"] }, "isSecret": { "enum": [true] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } } } }, @@ -206,7 +209,8 @@ "placeholderValue": { "type": "string" }, "patternKey": { "type": "string" }, "patternValue": { "type": "string" }, - "isSecret": { "enum": [false] } + "isSecret": { "enum": [false] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "unevaluatedProperties": false, "allOf": [ @@ -303,7 +307,8 @@ "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, "items": { "$ref": "#/definitions/arrayItems" }, - "isSecret": { "enum": [true] } + "isSecret": { "enum": [true] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } } } }, @@ -347,7 +352,8 @@ }, "additionalProperties": { "type": "boolean" - } + }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "unevaluatedProperties": false, "allOf": [ @@ -402,7 +408,8 @@ }, "additionalProperties": { "type": "boolean" - } + }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } } } }, @@ -422,7 +429,8 @@ "unit": { "type": "string" }, "editor": { "enum": ["number", "hidden"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -451,7 +459,8 @@ "unit": { "type": "string" }, "editor": { "enum": ["number", "hidden"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -479,7 +488,8 @@ "groupDescription": { "type": "string" }, "editor": { "enum": ["checkbox", "hidden"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -521,7 +531,8 @@ "minLength": { "type": "integer" }, "maxLength": { "type": "integer" }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "resourceType"], "allOf": [ @@ -580,7 +591,8 @@ "uniqueItems": { "type": "boolean" }, "resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "resourceType"], "allOf": [ @@ -632,7 +644,8 @@ "nullable": { "type": "boolean" }, "editor": { "enum": ["json", "hidden"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "editor"], "if": { @@ -667,7 +680,8 @@ "type": "array", "items": { "type": "string" }, "minItems": 1 - } + }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "enum"], "if": { @@ -694,7 +708,8 @@ "nullable": { "type": "boolean" }, "minLength": { "type": "integer" }, "maxLength": { "type": "integer" }, - "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "fileupload"] } + "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "fileupload"] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "allOf": [ @@ -746,7 +761,8 @@ "placeholderKey": { "type": "string" }, "placeholderValue": { "type": "string" }, "patternKey": { "type": "string" }, - "patternValue": { "type": "string" } + "patternValue": { "type": "string" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "unevaluatedProperties": false, @@ -842,7 +858,8 @@ "minimum": { "type": "integer" }, "maximum": { "type": "integer" }, "unit": { "type": "string" }, - "editor": { "enum": ["number", "hidden"] } + "editor": { "enum": ["number", "hidden"] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -869,7 +886,8 @@ "minimum": { "type": "number" }, "maximum": { "type": "number" }, "unit": { "type": "string" }, - "editor": { "enum": ["number", "hidden"] } + "editor": { "enum": ["number", "hidden"] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -895,7 +913,8 @@ "nullable": { "type": "boolean" }, "groupCaption": { "type": "string" }, "groupDescription": { "type": "string" }, - "editor": { "enum": ["checkbox", "hidden"] } + "editor": { "enum": ["checkbox", "hidden"] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -933,7 +952,8 @@ }, "additionalProperties": { "type": "boolean" - } + }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], "if": { @@ -973,7 +993,8 @@ "nullable": { "type": "boolean" }, "pattern": { "type": "string" }, "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } + "maxLength": { "type": "integer" }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "resourceType"], "if": { @@ -1013,7 +1034,8 @@ "minItems": { "type": "integer" }, "maxItems": { "type": "integer" }, "uniqueItems": { "type": "boolean" }, - "resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] } + "resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] }, + "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description", "resourceType"], "if": { @@ -1548,6 +1570,27 @@ "required": ["type"] } } + }, + "errorMessage": { + "title": "Utils: Custom error message definition", + "type": "object", + "properties": { + "type": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "string" }, + "maxLength": { "type": "string" }, + "enum": { "type": "string" }, + "minimum": { "type": "string" }, + "maximum": { "type": "string" }, + "minItems": { "type": "string" }, + "maxItems": { "type": "string" }, + "uniqueItems": { "type": "string" }, + "minProperties": { "type": "string" }, + "maxProperties": { "type": "string" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" } + }, + "additionalProperties": false } } } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 2e7fe394..3c9d269d 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -1067,5 +1067,54 @@ describe('input_schema.json', () => { ); }); }); + + describe('custom error messages', () => { + it('should allow defining custom error messages for validation keywords', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'textfield', + minLength: 5, + maxLength: 10, + pattern: '^[A-Z]+$', + errorMessage: { + minLength: 'Custom minLength error message', + maxLength: 'Custom maxLength error message', + pattern: 'Custom pattern error message', + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should not allow custom error messages for unknown keywords', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + minLength: 5, + errorMessage: { + unknownKeyword: 'This should not be allowed', + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + 'Input schema is not valid (Property schema.properties.myField.errorMessage.unknownKeyword is not allowed.)', + ); + }); + }); }); }); diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 27618aa1..b2e273b9 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1588,6 +1588,195 @@ describe('utilities.client', () => { expect(errorResults[3][0].message).toEqual('Field input.intField must be < 5'); }); }); + + describe('custom error messages', () => { + it('should return custom error messages for all validation keywords', () => { + const { inputSchema, validator } = buildInputSchema( + { + stringField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'textfield', + pattern: '^[A-Z]*$', + minLength: 2, + maxLength: 5, + errorMessage: { + type: 'stringField must be string', + pattern: 'stringField must have only uppercase letters', + minLength: 'stringField must be at least 2 characters long', + maxLength: 'stringField must be at most 5 characters long', + }, + }, + enumField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'textfield', + enum: ['A', 'B', 'C'], + errorMessage: { + enum: 'myField must be one of the allowed values: A, B, C', + }, + }, + intField: { + title: 'Field title', + description: 'My test field', + type: 'integer', + editor: 'number', + minimum: 1, + maximum: 10, + errorMessage: { + minimum: 'intField must be >= 1', + maximum: 'intField must be <= 10', + }, + }, + arrayField: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'json', + minItems: 2, + maxItems: 4, + uniqueItems: true, + errorMessage: { + minItems: 'arrayField must have at least 2 items', + maxItems: 'arrayField must have at most 4 items', + uniqueItems: 'arrayField must have unique items', + }, + }, + objectField: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'json', + minProperties: 1, + maxProperties: 3, + errorMessage: { + minProperties: 'objectField must have at least 1 property', + maxProperties: 'objectField must have at most 3 properties', + }, + }, + 'subSchema.Field': { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'schemaBased', + properties: { + nestedField: { + type: 'string', + title: 'Nested Field', + description: 'My nested field', + editor: 'textfield', + minLength: 3, + errorMessage: { + minLength: 'nestedField must be at least 3 characters long', + }, + }, + }, + }, + keyValue: { + title: 'Key Value Field', + description: 'My key value field', + type: 'array', + editor: 'keyValue', + patternKey: '^key_[0-9]+$', + patternValue: '^[A-Z]+$', + errorMessage: { + patternKey: 'All keys in keyValue must match the pattern ^key_[0-9]+$', + patternValue: 'All values in keyValue must match the pattern ^[A-Z]+$', + }, + }, + stringList: { + title: 'String List Field', + description: 'My string', + type: 'array', + editor: 'stringList', + patternValue: '^string_[0-9]+$', + errorMessage: { + patternValue: 'All items in stringList must match the pattern ^string_[0-9]+$', + }, + }, + object: { + title: 'Object Field', + description: 'My object field', + type: 'object', + editor: 'json', + patternKey: '^object_[0-9]+$', + patternValue: '^[a-z]+$', + errorMessage: { + patternKey: 'All keys in object must match the pattern ^object_[0-9]+$', + patternValue: 'All values in object must match the pattern ^[a-z]+$', + }, + }, + nullableField: { + title: 'Nullable Field', + description: 'My nullable field', + type: 'string', + editor: 'textfield', + nullable: true, + errorMessage: { + type: 'nullableField must be string or null', + }, + }, + }, + { + required: [], + }, + ); + + let errors = validateInputUsingValidator(validator, inputSchema, { stringField: 1 }); + expect(errors?.[0].message).toBe('stringField must be string'); + errors = validateInputUsingValidator(validator, inputSchema, { stringField: 'abc' }); + expect(errors?.[0].message).toBe('stringField must have only uppercase letters'); + errors = validateInputUsingValidator(validator, inputSchema, { stringField: 'A' }); + expect(errors?.[0].message).toBe('stringField must be at least 2 characters long'); + errors = validateInputUsingValidator(validator, inputSchema, { stringField: 'ABCDEFG' }); + expect(errors?.[0].message).toBe('stringField must be at most 5 characters long'); + + errors = validateInputUsingValidator(validator, inputSchema, { enumField: 'D' }); + expect(errors?.[0].message).toBe('myField must be one of the allowed values: A, B, C'); + + errors = validateInputUsingValidator(validator, inputSchema, { intField: 0 }); + expect(errors?.[0].message).toBe('intField must be >= 1'); + errors = validateInputUsingValidator(validator, inputSchema, { intField: 11 }); + expect(errors?.[0].message).toBe('intField must be <= 10'); + + errors = validateInputUsingValidator(validator, inputSchema, { arrayField: [1] }); + expect(errors?.[0].message).toBe('arrayField must have at least 2 items'); + errors = validateInputUsingValidator(validator, inputSchema, { arrayField: [1, 2, 3, 4, 5] }); + expect(errors?.[0].message).toBe('arrayField must have at most 4 items'); + errors = validateInputUsingValidator(validator, inputSchema, { arrayField: [1, 2, 2] }); + expect(errors?.[0].message).toBe('arrayField must have unique items'); + + errors = validateInputUsingValidator(validator, inputSchema, { objectField: {} }); + expect(errors?.[0].message).toBe('objectField must have at least 1 property'); + errors = validateInputUsingValidator(validator, inputSchema, { objectField: { a: 1, b: 2, c: 3, d: 4 } }); + expect(errors?.[0].message).toBe('objectField must have at most 3 properties'); + + errors = validateInputUsingValidator(validator, inputSchema, { 'subSchema.Field': { nestedField: 'ab' } }); + expect(errors?.[0].message).toBe('nestedField must be at least 3 characters long'); + + errors = validateInputUsingValidator(validator, inputSchema, { keyValue: [{ key: 'invalidKey', value: 'VALUE' }] }); + expect(errors?.[0].message).toBe('All keys in keyValue must match the pattern ^key_[0-9]+$'); + errors = validateInputUsingValidator(validator, inputSchema, { keyValue: [{ key: 'key_1', value: 'invalidValue' }] }); + expect(errors?.[0].message).toBe('All values in keyValue must match the pattern ^[A-Z]+$'); + + errors = validateInputUsingValidator(validator, inputSchema, { stringList: ['invalidItem'] }); + expect(errors?.[0].message).toBe('All items in stringList must match the pattern ^string_[0-9]+$'); + + errors = validateInputUsingValidator(validator, inputSchema, { object: { invalidKey: 'value' } }); + expect(errors?.[0].message).toBe('All keys in object must match the pattern ^object_[0-9]+$'); + errors = validateInputUsingValidator(validator, inputSchema, { object: { object_1: 'InvalidValue' } }); + expect(errors?.[0].message).toBe('All values in object must match the pattern ^[a-z]+$'); + + errors = validateInputUsingValidator(validator, inputSchema, { nullableField: 123 }); + expect(errors?.[0].message).toBe('nullableField must be string or null'); + errors = validateInputUsingValidator(validator, inputSchema, { nullableField: null }); + expect(errors).toEqual([]); + errors = validateInputUsingValidator(validator, inputSchema, {}); + expect(errors).toEqual([]); + }); + }); }); describe('#jsonStringifyExtended()', () => {