diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index af3f21b7..170537be 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -5,12 +5,10 @@ import { inputSchema as schema } from '@apify/json_schemas'; import { m } from './intl'; import type { - ArrayFieldDefinition, CommonResourceFieldDefinition, FieldDefinition, InputSchema, InputSchemaBaseChecked, - ObjectFieldDefinition, StringFieldDefinition, } from './types'; import { ensureAjvSupportsDraft2019, validateRegexpPattern } from './utilities'; @@ -143,6 +141,9 @@ export function parseAjvError( } else if (error.keyword === 'const') { fieldKey = cleanPropertyName(error.instancePath); message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); + } else if (error.keyword === 'pattern' && error.propertyName && error.params?.pattern) { + fieldKey = cleanPropertyName(`${error.instancePath}/${error.propertyName}`); + message = m('inputSchema.validation.propertyName', { rootName, fieldKey, pattern: error.params.pattern }); } else { fieldKey = cleanPropertyName(error.instancePath); message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); @@ -257,12 +258,21 @@ function validateField(validator: Ajv, fieldSchema: Record, fie validateFieldAgainstSchemaDefinition(validator, fieldSchema, fieldKey, isSubField); // Validate regex patterns if defined. - const { pattern } = fieldSchema as Partial; - const { patternKey, patternValue } = fieldSchema as Partial; - - if (pattern) validateRegexpPattern(pattern, `${fieldKey}.pattern`); - if (patternKey) validateRegexpPattern(patternKey, `${fieldKey}.patternKey`); - if (patternValue) validateRegexpPattern(patternValue, `${fieldKey}.patternValue`); + if ('pattern' in fieldSchema && fieldSchema.pattern) { + validateRegexpPattern(fieldSchema.pattern, `${fieldKey}.pattern`); + } + if ('patternKey' in fieldSchema && fieldSchema.patternKey) { + validateRegexpPattern(fieldSchema.patternKey, `${fieldKey}.patternKey`); + } + if ('patternValue' in fieldSchema && fieldSchema.patternValue) { + validateRegexpPattern(fieldSchema.patternValue, `${fieldKey}.patternValue`); + } + if ('propertyNames' in fieldSchema && fieldSchema.propertyNames?.pattern) { + validateRegexpPattern(fieldSchema.propertyNames.pattern, `${fieldKey}.propertyNames.pattern`); + } + if ('patternProperties' in fieldSchema && fieldSchema.patternProperties?.['.*']?.pattern) { + validateRegexpPattern(fieldSchema.patternProperties['.*'].pattern, `${fieldKey}.patternProperties.*.pattern`); + } } /** diff --git a/packages/input_schema/src/intl.ts b/packages/input_schema/src/intl.ts index b3e64ff5..fcc4fbeb 100644 --- a/packages/input_schema/src/intl.ts +++ b/packages/input_schema/src/intl.ts @@ -39,6 +39,8 @@ const intlStrings = { 'The regular expression "{pattern}" in field schema.properties.{fieldKey} must be valid.', 'inputSchema.validation.regexpNotSafe': 'The regular expression "{pattern}" in field schema.properties.{fieldKey} may cause excessive backtracking or be unsafe to execute.', + 'inputSchema.validation.propertyName': + 'Property name of {rootName}.{fieldKey} must match pattern "{pattern}".', }; /** diff --git a/packages/input_schema/src/types.ts b/packages/input_schema/src/types.ts index dead6344..385f33d4 100644 --- a/packages/input_schema/src/types.ts +++ b/packages/input_schema/src/types.ts @@ -56,6 +56,8 @@ export type ObjectFieldDefinition = CommonFieldDefinition & { properties?: Record; required?: string[]; additionalProperties?: boolean; + propertyNames?: { pattern: string }; + patternProperties?: { '.*': { type: 'string'; pattern: string; } }; } export type ArrayFieldDefinition = CommonFieldDefinition & { diff --git a/packages/json_schemas/schemas/input.schema.json b/packages/json_schemas/schemas/input.schema.json index bca56924..dfca4ca3 100644 --- a/packages/json_schemas/schemas/input.schema.json +++ b/packages/json_schemas/schemas/input.schema.json @@ -373,6 +373,8 @@ "additionalProperties": { "type": "boolean" }, + "propertyNames": { "$ref": "#/definitions/propertyNamesDefinition" }, + "patternProperties": { "$ref": "#/definitions/patternPropertiesDefinition" }, "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "unevaluatedProperties": false, @@ -429,6 +431,8 @@ "additionalProperties": { "type": "boolean" }, + "propertyNames": { "$ref": "#/definitions/propertyNamesDefinition" }, + "patternProperties": { "$ref": "#/definitions/patternPropertiesDefinition" }, "errorMessage": { "$ref": "#/definitions/errorMessage" } } } @@ -997,6 +1001,8 @@ "additionalProperties": { "type": "boolean" }, + "propertyNames": { "$ref": "#/definitions/propertyNamesDefinition" }, + "patternProperties": { "$ref": "#/definitions/patternPropertiesDefinition" }, "errorMessage": { "$ref": "#/definitions/errorMessage" } }, "required": ["type", "title", "description"], @@ -1644,6 +1650,32 @@ "patternValue": { "type": "string" } }, "additionalProperties": false + }, + "propertyNamesDefinition": { + "title": "Utils: Property names definition", + "type": "object", + "properties": { + "pattern": { "type": "string" } + }, + "additionalProperties": false, + "required": ["pattern"] + }, + "patternPropertiesDefinition": { + "title": "Utils: Pattern properties definition", + "type": "object", + "properties": { + ".*": { + "type": "object", + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" } + }, + "additionalProperties": false, + "required": ["type", "pattern"] + } + }, + "additionalProperties": false, + "required": [".*"] } } } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 4fd717f8..9628abfa 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -1256,6 +1256,15 @@ describe('input_schema.json', () => { editor: 'json', patternKey: '^[a-z]+$', patternValue: '^[0-9]+$', + propertyNames: { + pattern: '^[a-zA-Z_][a-zA-Z0-9_]*$', + }, + patternProperties: { + '.*': { + type: 'string', + pattern: '^[0-9]+$', + }, + }, }, arrayField: { title: 'Array field', @@ -1335,6 +1344,240 @@ describe('input_schema.json', () => { 'Input schema is not valid (The regular expression "^[0-9+$" in field schema.properties.objectField.patternValue must be valid.)', ); }); + + it('should throw error on invalid propertyNames regexp', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + objectField: { + title: 'Object field', + type: 'object', + description: 'Some description ...', + editor: 'json', + propertyNames: { + pattern: '^[0-9+$', // invalid regexp + }, + }, + }, + }; + + expect(() => validateInputSchema(validator, schema)).toThrow( + 'Input schema is not valid (The regular expression "^[0-9+$" in field schema.properties.objectField.propertyNames.pattern must be valid.)', + ); + }); + + it('should throw error on invalid patternProperties regexp', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + objectField: { + title: 'Object field', + type: 'object', + description: 'Some description ...', + editor: 'json', + patternProperties: { + '.*': { + type: 'string', + pattern: '^[0-9+$', // invalid regexp + }, + }, + }, + }, + }; + + expect(() => validateInputSchema(validator, schema)).toThrow( + // eslint-disable-next-line max-len + 'Input schema is not valid (The regular expression "^[0-9+$" in field schema.properties.objectField.patternProperties.*.pattern must be valid.)', + ); + }); + }); + + describe('propertyNames working correctly', () => { + it('should accept valid property names', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'My test field', + editor: 'json', + propertyNames: { + pattern: '^[a-zA-Z_][a-zA-Z0-9_]*$', + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should throw if pattern is missing', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'My test field', + editor: 'json', + propertyNames: { + // missing pattern + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + 'Input schema is not valid (Field schema.properties.myField.propertyNames.pattern is required)', + ); + }); + + it('should not allow propertyNames for other than object type', () => { + const types = { + string: 'textfield', + integer: 'number', + number: 'number', + boolean: 'checkbox', + array: 'json', + }; + Object.entries(types).forEach(([type, editor]) => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type, + description: 'My test field', + editor, + propertyNames: { + pattern: '^[a-zA-Z_][a-zA-Z0-9_]*$', + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + `Input schema is not valid (Property schema.properties.myField.propertyNames is not allowed.)`, + ); + }); + }); + }); + + describe('patternProperties working correctly', () => { + it('should accept valid patternProperties', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'My test field', + editor: 'json', + patternProperties: { + '.*': { + type: 'string', + pattern: '^[0-9]+$', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should throw if patternProperties value is missing type', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'My test field', + editor: 'json', + patternProperties: { + '.*': { + // missing type + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + 'Input schema is not valid (Field schema.properties.myField.patternProperties..*.type is required)', + ); + }); + + it('should not allow additional properties in patternProperties value', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'My test field', + editor: 'json', + patternProperties: { + '.*': { + type: 'string', + pattern: '^[0-9]+$', + extraProperty: 'not allowed', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + 'Input schema is not valid (Property schema.properties.myField.patternProperties..*.extraProperty is not allowed.)', + ); + }); + + it('should not allow patternProperties for other than object type', () => { + const types = { + string: 'textfield', + integer: 'number', + number: 'number', + boolean: 'checkbox', + array: 'json', + }; + Object.entries(types).forEach(([type, editor]) => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type, + description: 'My test field', + editor, + patternProperties: { + '^[a-zA-Z_][a-zA-Z0-9_]*$': { + type: 'string', + pattern: '^[0-9]+$', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow( + `Input schema is not valid (Property schema.properties.myField.patternProperties is not allowed.)`, + ); + }); + }); }); describe('custom error messages', () => { diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index b2e273b9..386baa5e 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1777,6 +1777,84 @@ describe('utilities.client', () => { expect(errors).toEqual([]); }); }); + + describe('special cases for propertyNames', () => { + it('should validate propertyNames for object field', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'schemaBased', + propertyNames: { + pattern: '^key_\\d+$', + }, + }, + }); + const validInputs = [ + { field: { key_1: 'value1', key_2: 'value2' } }, + { field: {} }, + ]; + const invalidInputs = [ + { field: { key1: 'value1' } }, + { field: { anotherKey: 'value2' } }, + ]; + + let errorResults = validInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(0); + + errorResults = invalidInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(2); + + expect(errorResults[0][0].message).toEqual('Property name of input.field.key1 must match pattern "^key_\\d+$".'); + expect(errorResults[1][0].message).toEqual('Property name of input.field.anotherKey must match pattern "^key_\\d+$".'); + }); + }); + + describe('special cases for patternProperties', () => { + it('should validate patternProperties for object field', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'schemaBased', + patternProperties: { + '.*': { + type: 'string', + pattern: '^value.*$', + }, + }, + }, + }); + const validInputs = [ + { field: { key1: 'value1', key2: 'value2' } }, + { field: { another: 'value' } }, + { field: {} }, + ]; + const invalidInputs = [ + { field: { key1: 123 } }, + { field: { anotherKey: 'invalid' } }, + ]; + + let errorResults = validInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(0); + + errorResults = invalidInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(2); + + expect(errorResults[0][0].message).toEqual('Field input.field.key1 must be string'); + expect(errorResults[1][0].message).toEqual('Field input.field.anotherKey must match pattern "^value.*$"'); + }); + }); }); describe('#jsonStringifyExtended()', () => {