From 2c3e57fb476d85827a48c9da963bf72dfcf22e1e Mon Sep 17 00:00:00 2001 From: martinforejt Date: Wed, 11 Jun 2025 17:32:09 +0200 Subject: [PATCH 1/9] feat(input_schema): Input sub-schema --- packages/input_schema/src/input_schema.ts | 60 +++- packages/input_schema/src/schema.json | 323 +++++++++++++++++++++- test/input_schema.test.ts | 87 ++++++ test/utilities.client.test.ts | 93 +++++++ 4 files changed, 544 insertions(+), 19 deletions(-) diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index f1929f13..9a1962c7 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -38,6 +38,11 @@ export function parseAjvError( let fieldKey: string; let message: string; + const cleanPropertyName = (name: string) => { + // remove leading and trailing slashes and replace remaining slashes with dots + return name.replace(/^\/|\/$/g, '').replace(/\//g, '.'); + }; + // 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') { @@ -48,20 +53,20 @@ export function parseAjvError( } message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); } else if (error.keyword === 'required') { - fieldKey = error.params.missingProperty; + fieldKey = cleanPropertyName(`${error.instancePath}/${error.params.missingProperty}`); message = m('inputSchema.validation.required', { rootName, fieldKey }); } else if (error.keyword === 'additionalProperties') { - fieldKey = error.params.additionalProperty; + fieldKey = cleanPropertyName(`${error.instancePath}/${error.params.additionalProperty}`); message = m('inputSchema.validation.additionalProperty', { rootName, fieldKey }); } else if (error.keyword === 'enum') { - fieldKey = error.instancePath.split('/').pop()!; + fieldKey = cleanPropertyName(error.instancePath); const errorMessage = `${error.message}: "${error.params.allowedValues.join('", "')}"`; message = m('inputSchema.validation.generic', { rootName, fieldKey, message: errorMessage }); } else if (error.keyword === 'const') { - fieldKey = error.instancePath.split('/').pop()!; + fieldKey = cleanPropertyName(error.instancePath); message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); } else { - fieldKey = error.instancePath.split('/').pop()!; + fieldKey = cleanPropertyName(error.instancePath); message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); } @@ -93,10 +98,19 @@ function validateBasicStructure(validator: Ajv, obj: Record): a /** * Validates particular field against it's schema. */ -function validateField(validator: Ajv, fieldSchema: Record, fieldKey: string): asserts fieldSchema is FieldDefinition { +function validateField(validator: Ajv, fieldSchema: Record, fieldKey: string, subField = false): asserts fieldSchema is FieldDefinition { const matchingDefinitions = Object .values(definitions) // cast as any, as the code in first branch seems to be invalid .filter((definition) => { + if (!subField && definition.title.startsWith('Sub-schema')) { + // This is a sub-schema definition, so we skip it. + return false; + } + if (subField && !definition.title.startsWith('Sub-schema')) { + // This is a normal definition, so we skip it. + return false; + } + return definition.properties.type.enum // This is a normal case where fieldSchema.type can be only one possible value matching definition.properties.type.enum.0 ? definition.properties.type.enum[0] === fieldSchema.type @@ -110,9 +124,18 @@ function validateField(validator: Ajv, fieldSchema: Record, fie throw new Error(`Input schema is not valid (${errorMessage})`); } + // When validating against schema of one definition, the definition can reference other definitions. + // So we need to add all of them to the schema. + function enhanceDefinition(definition: any) { + return { + ...definition, + definitions, + }; + } + // If there is only one matching then we are done and simply compare it. if (matchingDefinitions.length === 1) { - validateAgainstSchemaOrThrow(validator, fieldSchema, matchingDefinitions[0], `schema.properties.${fieldKey}`); + validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(matchingDefinitions[0]), `schema.properties.${fieldKey}`); return; } @@ -121,30 +144,41 @@ function validateField(validator: Ajv, fieldSchema: Record, fie if ((fieldSchema as StringFieldDefinition).enum) { const definition = matchingDefinitions.filter((item) => !!item.properties.enum).pop(); if (!definition) throw new Error('Input schema validation failed to find "enum property" definition'); - validateAgainstSchemaOrThrow(validator, fieldSchema, definition, `schema.properties.${fieldKey}.enum`); + validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(definition), `schema.properties.${fieldKey}.enum`); return; } // If the definition contains "resourceType" property then it's resource type. if ((fieldSchema as CommonResourceFieldDefinition).resourceType) { const definition = matchingDefinitions.filter((item) => !!item.properties.resourceType).pop(); if (!definition) throw new Error('Input schema validation failed to find "resource property" definition'); - validateAgainstSchemaOrThrow(validator, fieldSchema, definition, `schema.properties.${fieldKey}`); + validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(definition), `schema.properties.${fieldKey}`); return; } // Otherwise we use the other definition. const definition = matchingDefinitions.filter((item) => !item.properties.enum && !item.properties.resourceType).pop(); if (!definition) throw new Error('Input schema validation failed to find other than "enum property" definition'); - validateAgainstSchemaOrThrow(validator, fieldSchema, definition, `schema.properties.${fieldKey}`); + validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(definition), `schema.properties.${fieldKey}`); +} + +function validateSubFields(validator: Ajv, fieldSchema: InputSchemaBaseChecked, fieldKey: string) { + Object.entries(fieldSchema.properties).forEach(([subFieldKey, subFieldSchema]) => ( + validateField(validator, subFieldSchema, `${fieldKey}.${subFieldKey}`, true)), + ); } /** * Validates all properties in the input schema */ function validateProperties(inputSchema: InputSchemaBaseChecked, validator: Ajv): asserts inputSchema is InputSchema { - Object.entries(inputSchema.properties).forEach(([fieldKey, fieldSchema]) => ( - validateField(validator, fieldSchema, fieldKey)), - ); + Object.entries(inputSchema.properties).forEach(([fieldKey, fieldSchema]) => { + // The sub-properties has to be validated first, so we got more relevant error messages. + if ((fieldSchema as any).properties) { + // If the field has sub-fields, we need to validate them as well. + validateSubFields(validator, fieldSchema as any as InputSchemaBaseChecked, fieldKey); + } + validateField(validator, fieldSchema, fieldKey); + }); } /** diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index 34d1a6d3..a75545b7 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -166,7 +166,7 @@ "type": "object", "properties": { "type": { "enum": ["array"] }, - "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "hidden"] } + "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "schemaBased", "hidden"] } }, "additionalProperties": true, "required": ["type", "title", "description", "editor"], @@ -215,7 +215,7 @@ "additionalProperties": false, "properties": { "type": { "enum": ["array"] }, - "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "hidden"] }, + "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "schemaBased", "hidden"] }, "title": { "type": "string" }, "description": { "type": "string" }, "default": { "type": "array" }, @@ -230,7 +230,48 @@ "placeholderKey": { "type": "string" }, "placeholderValue": { "type": "string" }, "patternKey": { "type": "string" }, - "patternValue": { "type": "string" } + "patternValue": { "type": "string" }, + "items": { + "type": "object", + "properties": { + "type": { "enum": ["string", "integer", "boolean", "object"] } + }, + "required": ["type"], + "if": { + "properties": { + "type": { "enum": ["object"] } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string", "integer", "boolean"] } + } + } + } } } }, @@ -250,10 +291,35 @@ "nullable": { "type": "boolean" }, "minProperties": { "type": "integer" }, "maxProperties": { "type": "integer" }, - - "editor": { "enum": ["json", "proxy", "hidden"] }, + "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "sectionDescription": { "type": "string" }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } }, "required": ["type", "title", "description", "editor"] }, @@ -419,6 +485,251 @@ "sectionDescription": { "type": "string" } }, "required": ["type", "title", "description", "editor"] + }, + "subSchemaStringEnumProperty": { + "title": "Sub-schema Enum property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "editor": { "enum": ["select"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "enum": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + }, + "enumTitles": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + }, + "required": ["type", "title", "description", "enum"] + }, + "subSchemaStringProperty": { + "title": "Sub-schema String property", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "fileupload"] } + }, + "required": ["type", "title", "description", "editor"], + "if": { + "properties": { + "editor": { "const": "datepicker" } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" }, + "editor": { "enum": ["datepicker"] }, + "dateType": { "enum": ["absolute", "relative", "absoluteOrRelative"] } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" }, + "editor": { "enum": ["javascript", "python", "textfield", "textarea", "hidden", "fileupload"] } + } + } + }, + "subSchemaArrayProperty": { + "title": "Sub-schema Array property", + "type": "object", + "properties": { + "type": { "enum": ["array"] }, + "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "hidden"] } + }, + "additionalProperties": true, + "required": ["type", "title", "description", "editor"], + "if": { + "properties": { + "editor": { "const": "select" } + } + }, + "then": { + "additionalProperties": false, + "required": ["items"], + "properties": { + "type": { "enum": ["array"] }, + "editor": { "enum": ["select"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minItems": { "type": "integer" }, + "maxItems": { "type": "integer" }, + "uniqueItems": { "type": "boolean" }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "enum": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "enumTitles": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "enum"] + } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "hidden"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minItems": { "type": "integer" }, + "maxItems": { "type": "integer" }, + "uniqueItems": { "type": "boolean" }, + "placeholderKey": { "type": "string" }, + "placeholderValue": { "type": "string" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" }, + "items": { + "type": "object", + "properties": { + "type": { "enum": ["string", "integer", "boolean", "object"] } + }, + "required": ["type"], + "if": { + "properties": { + "type": { "enum": ["object"] } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string", "integer", "boolean"] } + } + } + } + } + } + }, + "subSchemaIntegerProperty": { + "title": "Sub-schema Integer property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["integer"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minimum": { "type": "integer" }, + "maximum": { "type": "integer" }, + "unit": { "type": "string" }, + "editor": { "enum": ["number", "hidden"] } + }, + "required": ["type", "title", "description"] + }, + "subSchemaBooleanProperty": { + "title": "Sub-schema Boolean property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["boolean"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "groupCaption": { "type": "string" }, + "groupDescription": { "type": "string" }, + "editor": { "enum": ["checkbox", "hidden"] } + }, + "required": ["type", "title", "description"] + }, + "subSchemaObjectProperty": { + "title": "Sub-schema Object property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minProperties": { "type": "integer" }, + "maxProperties": { "type": "integer" }, + "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" } + ] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } + }, + "required": ["type", "title", "description", "editor"] } } } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 9d756df6..6eb9fe98 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -538,5 +538,92 @@ describe('input_schema.json', () => { ); }); }); + + describe('special cases for sub-schema', () => { + it('should accept valid object sub-schema', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'object', + description: 'Description', + editor: 'schemaBased', + additionalProperties: false, + // required: ['key2'], + properties: { + key: { + type: 'object', + title: 'Key', + description: 'Key description', + editor: 'schemaBased', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + editor: 'textfield', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + editor: 'textfield', + }, + }, + }, + }, + }, + }, + }; + + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should accept valid array sub-schema', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'array', + description: 'Description', + editor: 'schemaBased', + items: { + type: 'object', + properties: { + key: { + type: 'object', + title: 'Key', + description: 'Key description', + editor: 'schemaBased', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + editor: 'textfield', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + editor: 'textfield', + }, + }, + }, + }, + }, + }, + }, + }; + + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + }); }); }); diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 2681d194..a2eb2243 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1088,6 +1088,99 @@ describe('utilities.client', () => { }); }); }); + + describe('special cases for sub-schema', () => { + it('should allow sub-schema for object property', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'schemaBased', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Description for key 1', + editor: 'textfield', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Description for key 2', + editor: 'textfield', + }, + }, + additionalProperties: false, + required: ['key1'], + }, + }); + const inputs = [ + // 4 invalid inputs + { field: [] }, + { field: {} }, + { field: { key2: 'value' } }, + { field: { key3: 'value' } }, + // 2 valid inputs + { field: { key1: 'value' } }, + { field: { key1: 'value', key2: 'value' } }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 4 invalid inputs + expect(results.length).toEqual(4); + }); + + it('should allow sub-schema for array property', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'schemaBased', + items: { + type: 'object', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Description for key 1', + editor: 'textfield', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Description for key 2', + editor: 'textfield', + }, + }, + additionalProperties: false, + required: ['key1'], + }, + }, + }); + const inputs = [ + // 3 invalid inputs + { field: {} }, + { field: [{ key2: 'value' }] }, + { field: [{ key3: 'value' }] }, + // 2 valid inputs + { field: [{ key1: 'value' }] }, + { field: [{ key1: 'value' }, { key1: 'value' }] }, + { field: [{ key1: 'value', key2: 'value' }, { key1: 'value' }] }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 4 invalid inputs + expect(results.length).toEqual(3); + }); + }); }); describe('#jsonStringifyExtended()', () => { From a0ad3d2f2b9099b14ce5a65456f686a034923da5 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Thu, 12 Jun 2025 10:20:54 +0200 Subject: [PATCH 2/9] fix tests --- test/input_schema.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 6eb9fe98..bb6013f5 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -413,7 +413,8 @@ describe('input_schema.json', () => { }, }; expect(() => validateInputSchema(validator, schema)).toThrow( - 'Input schema is not valid (Field schema.properties.myField.0 must be equal to one of the allowed values: "READ", "WRITE")', + // eslint-disable-next-line max-len + 'Input schema is not valid (Field schema.properties.myField.resourcePermissions.0 must be equal to one of the allowed values: "READ", "WRITE")', ); const schema2 = { @@ -431,7 +432,8 @@ describe('input_schema.json', () => { }, }; expect(() => validateInputSchema(validator, schema2)).toThrow( - 'Input schema is not valid (Field schema.properties.myFieldArray.0 must be equal to one of the allowed values: "READ", "WRITE")', + // eslint-disable-next-line max-len + 'Input schema is not valid (Field schema.properties.myFieldArray.resourcePermissions.0 must be equal to one of the allowed values: "READ", "WRITE")', ); }); From f42426776d20a1d592b867bd7ea4404069664ac0 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Thu, 12 Jun 2025 17:46:49 +0200 Subject: [PATCH 3/9] enable arrays of arrays --- packages/input_schema/src/input_schema.ts | 5 + packages/input_schema/src/schema.json | 204 +++++++++++++--------- test/input_schema.test.ts | 46 +++++ 3 files changed, 175 insertions(+), 80 deletions(-) diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index 9a1962c7..f3ba8594 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -102,6 +102,11 @@ function validateField(validator: Ajv, fieldSchema: Record, fie const matchingDefinitions = Object .values(definitions) // cast as any, as the code in first branch seems to be invalid .filter((definition) => { + if (definition.title.startsWith('Utils')) { + // Utility definitions are not used for property validation. + // They are used for their internal logic. + return false; + } if (!subField && definition.title.startsWith('Sub-schema')) { // This is a sub-schema definition, so we skip it. return false; diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index a75545b7..372dd28b 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -232,45 +232,7 @@ "patternKey": { "type": "string" }, "patternValue": { "type": "string" }, "items": { - "type": "object", - "properties": { - "type": { "enum": ["string", "integer", "boolean", "object"] } - }, - "required": ["type"], - "if": { - "properties": { - "type": { "enum": ["object"] } - } - }, - "then": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" } - ] - } - }, - "additionalProperties": false, - "minProperties": 1 - } - } - }, - "else": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["string", "integer", "boolean"] } - } - } + "$ref": "#/definitions/arrayItemsProperty" } } } @@ -304,7 +266,9 @@ { "$ref": "#/definitions/subSchemaArrayProperty" }, { "$ref": "#/definitions/subSchemaObjectProperty" }, { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" } + { "$ref": "#/definitions/subSchemaBooleanProperty" }, + { "$ref": "#/definitions/subSchemaResourceProperty" }, + { "$ref": "#/definitions/subSchemaResourceArrayProperty" } ] } }, @@ -616,45 +580,7 @@ "patternKey": { "type": "string" }, "patternValue": { "type": "string" }, "items": { - "type": "object", - "properties": { - "type": { "enum": ["string", "integer", "boolean", "object"] } - }, - "required": ["type"], - "if": { - "properties": { - "type": { "enum": ["object"] } - } - }, - "then": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" } - ] - } - }, - "additionalProperties": false, - "minProperties": 1 - } - } - }, - "else": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["string", "integer", "boolean"] } - } - } + "$ref": "#/definitions/arrayItemsProperty" } } } @@ -714,7 +640,9 @@ { "$ref": "#/definitions/subSchemaArrayProperty" }, { "$ref": "#/definitions/subSchemaObjectProperty" }, { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" } + { "$ref": "#/definitions/subSchemaBooleanProperty" }, + { "$ref": "#/definitions/subSchemaResourceProperty" }, + { "$ref": "#/definitions/subSchemaResourceArrayProperty" } ] } } @@ -730,6 +658,122 @@ } }, "required": ["type", "title", "description", "editor"] + }, + "subSchemaResourceProperty": { + "title": "Sub-schema Resource property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "editor": { "enum": ["resourcePicker", "hidden"] }, + "resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] }, + "resourcePermissions": { + "type": "array", + "items": { + "type": "string", + "enum": ["READ", "WRITE"] + }, + "minItems": 1, + "uniqueItems": true, + "contains": { + "const": "READ" + } + }, + "nullable": { "type": "boolean" } + }, + "required": ["type", "title", "description", "resourceType"] + }, + "subSchemaResourceArrayProperty": { + "title": "Sub-schema Resource array property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "editor": { "enum": ["resourcePicker", "hidden"] }, + "resourcePermissions": { + "type": "array", + "items": { + "type": "string", + "enum": ["READ", "WRITE"] + }, + "minItems": 1, + "uniqueItems": true, + "contains": { + "const": "READ" + } + }, + "nullable": { "type": "boolean" }, + "minItems": { "type": "integer" }, + "maxItems": { "type": "integer" }, + "uniqueItems": { "type": "boolean" }, + "resourceType": { "enum": ["dataset", "keyValueStore", "requestQueue"] } + }, + "required": ["type", "title", "description", "resourceType"] + }, + "arrayItemsProperty": { + "title": "Utils Array items property", + "type": "object", + "properties": { + "type": { "enum": ["string", "integer", "boolean", "object", "array"] } + }, + "required": ["type"], + "if": { + "properties": { + "type": { "enum": ["object"] } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" }, + { "$ref": "#/definitions/subSchemaResourceProperty" }, + { "$ref": "#/definitions/subSchemaResourceArrayProperty" } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + } + } + }, + "else": { + "additionalProperties": true, + "if": { + "properties": { + "type": { "const": "array" } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "items": { + "$ref": "#/definitions/arrayItemsProperty" + } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string", "integer", "boolean"] } + } + } + } } } } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index bb6013f5..d977a529 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -626,6 +626,52 @@ describe('input_schema.json', () => { expect(() => validateInputSchema(validator, schema)).not.toThrow(); }); + + it('should accept valid 2D array sub-schema', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + type: 'array', + description: 'Description', + editor: 'schemaBased', + items: { + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'object', + title: 'Key', + description: 'Key description', + editor: 'schemaBased', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + editor: 'textfield', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + editor: 'textfield', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); }); }); }); From 4585ed192a1b122345186a8f5cf97431c136ef3b Mon Sep 17 00:00:00 2001 From: martinforejt Date: Mon, 7 Jul 2025 17:30:06 +0200 Subject: [PATCH 4/9] wip: limit sub-schema for editors --- packages/input_schema/src/schema.json | 668 ++++++++++++++++++++------ 1 file changed, 512 insertions(+), 156 deletions(-) diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index 1849c112..c97a7e73 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -170,7 +170,6 @@ "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "schemaBased", "hidden"] }, "isSecret": { "type": "boolean" } }, - "additionalProperties": true, "required": ["type", "title", "description", "editor"], "if": { "properties": { @@ -182,74 +181,42 @@ } }, "then": { - "if": { - "properties": { - "editor": { "const": "select" } - } - }, - "then": { - "additionalProperties": false, - "required": ["items"], - "properties": { - "type": { "enum": ["array"] }, - "editor": { "enum": ["select"] }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "default": { "type": "array" }, - "prefill": { "type": "array" }, - "example": { "type": "array" }, - "nullable": { "type": "boolean" }, - "minItems": { "type": "integer" }, - "maxItems": { "type": "integer" }, - "uniqueItems": { "type": "boolean" }, - "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" }, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "enum": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "enumTitles": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["type", "enum"] - }, - "isSecret": { "enum": [false] } - } + "properties": { + "type": { "enum": ["array"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": { "type": "array" }, + "prefill": { "type": "array" }, + "example": { "type": "array" }, + "nullable": { "type": "boolean" }, + "minItems": { "type": "integer" }, + "maxItems": { "type": "integer" }, + "uniqueItems": { "type": "boolean" }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" }, + "placeholderKey": { "type": "string" }, + "placeholderValue": { "type": "string" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" }, + "isSecret": { "enum": [false] } }, - "else": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["array"] }, - "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "schemaBased", "hidden"] }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "default": { "type": "array" }, - "prefill": { "type": "array" }, - "example": { "type": "array" }, - "nullable": { "type": "boolean" }, - "minItems": { "type": "integer" }, - "maxItems": { "type": "integer" }, - "uniqueItems": { "type": "boolean" }, - "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" }, - "placeholderKey": { "type": "string" }, - "placeholderValue": { "type": "string" }, - "patternKey": { "type": "string" }, - "patternValue": { "type": "string" }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" - }, - "isSecret": { "enum": [false] } + "unevaluatedProperties": false, + "oneOf": [ + { "$ref": "#/definitions/selectProperty" }, + { "$ref": "#/definitions/keyValueProperty" }, + { "$ref": "#/definitions/stringListProperty" }, + { "$ref": "#/definitions/globsProperty" }, + { "$ref": "#/definitions/pseudoUrlsProperty" }, + { "$ref": "#/definitions/requestListSourcesProperty" }, + { + "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { + "$ref": "#/definitions/arrayItemsProperty" + } + } } - } + ] }, "else": { "additionalProperties": false, @@ -276,7 +243,6 @@ "objectProperty": { "title": "Object property", "type": "object", - "additionalProperties": true, "properties": { "type": { "enum": ["object"] }, "title": { "type": "string" }, @@ -295,7 +261,6 @@ } }, "then": { - "additionalProperties": false, "properties": { "type": { "enum": ["object"] }, "title": { "type": "string" }, @@ -311,36 +276,101 @@ "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" }, - { "$ref": "#/definitions/subSchemaResourceProperty" }, - { "$ref": "#/definitions/subSchemaResourceArrayProperty" } - ] + "isSecret": { "enum": [false] } + }, + "unevaluatedProperties": false, + "oneOf": [ + { + "properties": { + "editor": { "enum": ["proxy"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "useApifyProxy": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["boolean"] } + }, + "required": ["type"] + }, + "apifyProxyGroups": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "items": { "type": "string" } + }, + "required": ["type"] + }, + "proxyUrls": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "items": { "type": "string" } + }, + "required": ["type"] + }, + "apifyProxyCountry": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" } - }, - "additionalProperties": false, - "minProperties": 1 - }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true - }, - "additionalProperties": { - "type": "boolean" + } }, - "isSecret": { "enum": [false] } - } + { + "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "properties": { + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" }, + { "$ref": "#/definitions/subSchemaResourceProperty" }, + { "$ref": "#/definitions/subSchemaResourceArrayProperty" } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } + } + } + ] }, "else": { "additionalProperties": false, @@ -588,7 +618,7 @@ "nullable": { "type": "boolean" }, "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "fileupload"] } }, - "required": ["type", "title", "description", "editor"], + "required": ["type", "title", "description"], "if": { "properties": { "editor": { "const": "datepicker" } @@ -627,66 +657,39 @@ "type": "object", "properties": { "type": { "enum": ["array"] }, - "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "hidden"] } - }, - "additionalProperties": true, - "required": ["type", "title", "description", "editor"], - "if": { - "properties": { - "editor": { "const": "select" } - } - }, - "then": { - "additionalProperties": false, - "required": ["items"], - "properties": { - "type": { "enum": ["array"] }, - "editor": { "enum": ["select"] }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "nullable": { "type": "boolean" }, - "minItems": { "type": "integer" }, - "maxItems": { "type": "integer" }, - "uniqueItems": { "type": "boolean" }, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "enum": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "enumTitles": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["type", "enum"] - } + "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "select", "hidden"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minItems": { "type": "integer" }, + "maxItems": { "type": "integer" }, + "uniqueItems": { "type": "boolean" }, + "placeholderKey": { "type": "string" }, + "placeholderValue": { "type": "string" }, + "patternKey": { "type": "string" }, + "patternValue": { "type": "string" }, + "items": { + "$ref": "#/definitions/arrayItemsProperty" } }, - "else": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["array"] }, - "editor": { "enum": ["json", "requestListSources", "pseudoUrls", "globs", "keyValue", "stringList", "hidden"] }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "nullable": { "type": "boolean" }, - "minItems": { "type": "integer" }, - "maxItems": { "type": "integer" }, - "uniqueItems": { "type": "boolean" }, - "placeholderKey": { "type": "string" }, - "placeholderValue": { "type": "string" }, - "patternKey": { "type": "string" }, - "patternValue": { "type": "string" }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" + "required": ["type", "title", "description"], + "unevaluatedProperties": false, + "oneOf": [ + { "$ref": "#/definitions/selectProperty" }, + { "$ref": "#/definitions/keyValueProperty" }, + { "$ref": "#/definitions/stringListProperty" }, + { "$ref": "#/definitions/globsProperty" }, + { "$ref": "#/definitions/pseudoUrlsProperty" }, + { "$ref": "#/definitions/requestListSourcesProperty" }, + { + "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { + "$ref": "#/definitions/arrayItemsProperty" + } } } - } + ] }, "subSchemaIntegerProperty": { "title": "Sub-schema Integer property", @@ -732,7 +735,7 @@ "nullable": { "type": "boolean" }, "minProperties": { "type": "integer" }, "maxProperties": { "type": "integer" }, - "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, + "editor": { "enum": ["json", "proxy", "hidden"] }, "properties": { "type": "object", "patternProperties": { @@ -760,7 +763,7 @@ "type": "boolean" } }, - "required": ["type", "title", "description", "editor"] + "required": ["type", "title", "description"] }, "subSchemaResourceProperty": { "title": "Sub-schema Resource property", @@ -851,6 +854,15 @@ }, "additionalProperties": false, "minProperties": 1 + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" } } }, @@ -877,6 +889,350 @@ } } } + }, + "selectProperty": { + "title": "Utils Select property", + "type": "object", + "properties": { + "editor": { "enum": ["select"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "enum": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "enumTitles": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "enum"] + } + }, + "required": ["items"] + }, + "keyValueProperty": { + "title": "Utils KeyValue property", + "type": "object", + "properties": { + "editor": { "enum": ["keyValue"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "properties": { + "key": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + } + }, + "required": ["key", "value"], + "additionalProperties": false, + "minProperties": 1 + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type", "properties"] + } + } + }, + "stringListProperty": { + "title": "Utils String List property", + "type": "object", + "properties": { + "editor": { "enum": ["stringList"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + } + } + }, + "globsProperty": { + "title": "Utils Globs property", + "type": "object", + "properties": { + "editor": { "enum": ["globs"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "glob": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] + } + } + }, + "pseudoUrlsProperty": { + "title": "Utils Pseudo URLs property", + "type": "object", + "properties": { + "editor": { "enum": ["pseudoUrls"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "purl": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] + } + } + }, + "requestListSourcesProperty": { + "title": "Utils Request List Sources property", + "type": "object", + "properties": { + "editor": { "enum": ["requestListSources"] }, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object" + } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] + } + } } } } From 4b91ad27418e23924efa61cd7593ebdf1fca7cd1 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Wed, 9 Jul 2025 11:46:12 +0200 Subject: [PATCH 5/9] cleanup schema, additional tests --- packages/input_schema/src/input_schema.ts | 13 +- packages/input_schema/src/schema.json | 931 +++++++++++----------- test/input_schema.test.ts | 15 +- test/input_schema_definition.test.ts | 154 +++- test/utilities.client.test.ts | 2 +- 5 files changed, 621 insertions(+), 494 deletions(-) diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index f3ba8594..8351d125 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -43,6 +43,8 @@ export function parseAjvError( return name.replace(/^\/|\/$/g, '').replace(/\//g, '.'); }; + console.log(error); + // 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') { @@ -58,6 +60,9 @@ export function parseAjvError( } else if (error.keyword === 'additionalProperties') { fieldKey = cleanPropertyName(`${error.instancePath}/${error.params.additionalProperty}`); message = m('inputSchema.validation.additionalProperty', { rootName, fieldKey }); + } else if (error.keyword === 'unevaluatedProperties') { + fieldKey = cleanPropertyName(`${error.instancePath}/${error.params.unevaluatedProperty}`); + message = m('inputSchema.validation.additionalProperty', { rootName, fieldKey }); } else if (error.keyword === 'enum') { fieldKey = cleanPropertyName(error.instancePath); const errorMessage = `${error.message}: "${error.params.allowedValues.join('", "')}"`; @@ -102,12 +107,12 @@ function validateField(validator: Ajv, fieldSchema: Record, fie const matchingDefinitions = Object .values(definitions) // cast as any, as the code in first branch seems to be invalid .filter((definition) => { - if (definition.title.startsWith('Utils')) { + if (definition.title.startsWith('Utils:')) { // Utility definitions are not used for property validation. // They are used for their internal logic. return false; } - if (!subField && definition.title.startsWith('Sub-schema')) { + if (!subField && definition.title.startsWith('Sub-schema:')) { // This is a sub-schema definition, so we skip it. return false; } @@ -207,6 +212,10 @@ export function validateExistenceOfRequiredFields(inputSchema: InputSchema) { * then checks that all required fields are present and finally checks fully against the whole schema. * * This way we get the most accurate error message for user. + * + * @param validator An instance of AJV validator. Important: The JSON Schema that the passed input schema is validated against + * is using features from JSON Schema 2019 draft, so the AJV instance must support it. + * @param inputSchema Input schema to validate. */ export function validateInputSchema(validator: Ajv, inputSchema: Record): asserts inputSchema is InputSchema { // First validate just basic structure without fields. diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index c97a7e73..fc1c2bc1 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -173,11 +173,7 @@ "required": ["type", "title", "description", "editor"], "if": { "properties": { - "isSecret": { - "not": { - "const": true - } - } + "isSecret": { "not": { "const": true } } } }, "then": { @@ -202,20 +198,34 @@ }, "unevaluatedProperties": false, "oneOf": [ - { "$ref": "#/definitions/selectProperty" }, - { "$ref": "#/definitions/keyValueProperty" }, - { "$ref": "#/definitions/stringListProperty" }, - { "$ref": "#/definitions/globsProperty" }, - { "$ref": "#/definitions/pseudoUrlsProperty" }, - { "$ref": "#/definitions/requestListSourcesProperty" }, - { - "properties": { - "editor": { "enum": ["json", "schemaBased", "hidden"] }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" - } - } - } + { "properties": { + "editor": { "enum": ["select"] }, + "items": { "$ref": "#/definitions/arrayItemsSelect" } + }}, + { "properties": { + "editor": { "enum": ["keyValue"] }, + "items": { "$ref": "#/definitions/arrayItemsKeyValue" } + }}, + { "properties": { + "editor": { "enum": ["stringList"] }, + "items": { "$ref": "#/definitions/arrayItemsStringList" } + }}, + { "properties": { + "editor": { "enum": ["globs"] }, + "items": { "$ref": "#/definitions/arrayItemsGlobs" } + }}, + { "properties": { + "editor": { "enum": ["pseudoUrls"] }, + "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } + }}, + { "properties": { + "editor": { "enum": ["requestListSources"] }, + "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } + }}, + { "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { "$ref": "#/definitions/arrayItems" } + }} ] }, "else": { @@ -233,9 +243,7 @@ "uniqueItems": { "type": "boolean" }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" - }, + "items": { "$ref": "#/definitions/arrayItems" }, "isSecret": { "enum": [true] } } } @@ -253,11 +261,7 @@ "required": ["type", "title", "description", "editor"], "if": { "properties": { - "isSecret": { - "not": { - "const": true - } - } + "isSecret": { "not": { "const": true } } } }, "then": { @@ -276,100 +280,27 @@ "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "isSecret": { "enum": [false] } + "isSecret": { "enum": [false] }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } }, "unevaluatedProperties": false, "oneOf": [ - { - "properties": { - "editor": { "enum": ["proxy"] }, - "properties": { - "type": "object", - "additionalProperties": false, - "properties": { - "useApifyProxy": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["boolean"] } - }, - "required": ["type"] - }, - "apifyProxyGroups": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["array"] }, - "items": { "type": "string" } - }, - "required": ["type"] - }, - "proxyUrls": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["array"] }, - "items": { "type": "string" } - }, - "required": ["type"] - }, - "apifyProxyCountry": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - } - } - }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true - }, - "additionalProperties": { - "type": "boolean" - } - } - }, - { - "properties": { - "editor": { "enum": ["json", "schemaBased", "hidden"] }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" }, - { "$ref": "#/definitions/subSchemaResourceProperty" }, - { "$ref": "#/definitions/subSchemaResourceArrayProperty" } - ] - } - }, - "additionalProperties": false, - "minProperties": 1 - }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true - }, - "additionalProperties": { - "type": "boolean" - } - } - } + { "properties": { + "editor": { "enum": ["proxy"] }, + "properties": { "$ref": "#/definitions/subObjectPropertiesProxy" } + }}, + { "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "properties": { "$ref": "#/definitions/subObjectProperties" } + }} ] }, "else": { @@ -388,25 +319,8 @@ "editor": { "enum": ["json", "hidden"] }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" }, - { "$ref": "#/definitions/subSchemaResourceProperty" }, - { "$ref": "#/definitions/subSchemaResourceArrayProperty" } - ] - } - }, - "additionalProperties": false, - "minProperties": 1 - }, + "properties": { "$ref": "#/definitions/subObjectProperties" }, + "isSecret": { "enum": [true] }, "required": { "type": "array", "minItems": 0, @@ -415,8 +329,7 @@ }, "additionalProperties": { "type": "boolean" - }, - "isSecret": { "enum": [true] } + } } } }, @@ -584,7 +497,7 @@ "required": ["type", "title", "description", "editor"] }, "subSchemaStringEnumProperty": { - "title": "Sub-schema Enum property", + "title": "Sub-schema: Enum property", "type": "object", "additionalProperties": false, "properties": { @@ -608,7 +521,7 @@ "required": ["type", "title", "description", "enum"] }, "subSchemaStringProperty": { - "title": "Sub-schema String property", + "title": "Sub-schema: String property", "type": "object", "additionalProperties": true, "properties": { @@ -653,7 +566,7 @@ } }, "subSchemaArrayProperty": { - "title": "Sub-schema Array property", + "title": "Sub-schema: Array property", "type": "object", "properties": { "type": { "enum": ["array"] }, @@ -667,32 +580,43 @@ "placeholderKey": { "type": "string" }, "placeholderValue": { "type": "string" }, "patternKey": { "type": "string" }, - "patternValue": { "type": "string" }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" - } + "patternValue": { "type": "string" } }, "required": ["type", "title", "description"], "unevaluatedProperties": false, "oneOf": [ - { "$ref": "#/definitions/selectProperty" }, - { "$ref": "#/definitions/keyValueProperty" }, - { "$ref": "#/definitions/stringListProperty" }, - { "$ref": "#/definitions/globsProperty" }, - { "$ref": "#/definitions/pseudoUrlsProperty" }, - { "$ref": "#/definitions/requestListSourcesProperty" }, - { - "properties": { - "editor": { "enum": ["json", "schemaBased", "hidden"] }, - "items": { - "$ref": "#/definitions/arrayItemsProperty" - } - } - } + { "properties": { + "editor": { "enum": ["select"] }, + "items": { "$ref": "#/definitions/arrayItemsSelect" } + }}, + { "properties": { + "editor": { "enum": ["keyValue"] }, + "items": { "$ref": "#/definitions/arrayItemsKeyValue" } + }}, + { "properties": { + "editor": { "enum": ["stringList"] }, + "items": { "$ref": "#/definitions/arrayItemsStringList" } + }}, + { "properties": { + "editor": { "enum": ["globs"] }, + "items": { "$ref": "#/definitions/arrayItemsGlobs" } + }}, + { "properties": { + "editor": { "enum": ["pseudoUrls"] }, + "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } + }}, + { "properties": { + "editor": { "enum": ["requestListSources"] }, + "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } + }}, + { "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { "$ref": "#/definitions/arrayItems" } + }} ] }, "subSchemaIntegerProperty": { - "title": "Sub-schema Integer property", + "title": "Sub-schema: Integer property", "type": "object", "additionalProperties": false, "properties": { @@ -708,7 +632,7 @@ "required": ["type", "title", "description"] }, "subSchemaBooleanProperty": { - "title": "Sub-schema Boolean property", + "title": "Sub-schema: Boolean property", "type": "object", "additionalProperties": false, "properties": { @@ -723,7 +647,7 @@ "required": ["type", "title", "description"] }, "subSchemaObjectProperty": { - "title": "Sub-schema Object property", + "title": "Sub-schema: Object property", "type": "object", "additionalProperties": false, "properties": { @@ -736,23 +660,7 @@ "minProperties": { "type": "integer" }, "maxProperties": { "type": "integer" }, "editor": { "enum": ["json", "proxy", "hidden"] }, - "properties": { - "type": "object", - "patternProperties": { - "^": { - "oneOf": [ - { "$ref": "#/definitions/subSchemaStringProperty" }, - { "$ref": "#/definitions/subSchemaStringEnumProperty" }, - { "$ref": "#/definitions/subSchemaArrayProperty" }, - { "$ref": "#/definitions/subSchemaObjectProperty" }, - { "$ref": "#/definitions/subSchemaIntegerProperty" }, - { "$ref": "#/definitions/subSchemaBooleanProperty" }, - { "$ref": "#/definitions/subSchemaResourceProperty" }, - { "$ref": "#/definitions/subSchemaResourceArrayProperty" } - ] - } - } - }, + "properties": { "$ref": "#/definitions/subObjectProperties" }, "required": { "type": "array", "minItems": 0, @@ -766,7 +674,7 @@ "required": ["type", "title", "description"] }, "subSchemaResourceProperty": { - "title": "Sub-schema Resource property", + "title": "Sub-schema: Resource property", "type": "object", "additionalProperties": false, "properties": { @@ -792,7 +700,7 @@ "required": ["type", "title", "description", "resourceType"] }, "subSchemaResourceArrayProperty": { - "title": "Sub-schema Resource array property", + "title": "Sub-schema: Resource array property", "type": "object", "additionalProperties": false, "properties": { @@ -820,8 +728,8 @@ }, "required": ["type", "title", "description", "resourceType"] }, - "arrayItemsProperty": { - "title": "Utils Array items property", + "arrayItems": { + "title": "Utils: Array items definition", "type": "object", "properties": { "type": { "enum": ["string", "integer", "boolean", "object", "array"] } @@ -852,8 +760,7 @@ ] } }, - "additionalProperties": false, - "minProperties": 1 + "additionalProperties": false }, "required": { "type": "array", @@ -867,18 +774,15 @@ } }, "else": { - "additionalProperties": true, "if": { - "properties": { - "type": { "const": "array" } - } + "properties": { "type": { "const": "array" } } }, "then": { "additionalProperties": false, "properties": { "type": { "enum": ["array"] }, "items": { - "$ref": "#/definitions/arrayItemsProperty" + "$ref": "#/definitions/arrayItems" } } }, @@ -890,345 +794,414 @@ } } }, - "selectProperty": { - "title": "Utils Select property", + "arrayItemsSelect": { + "title": "Utils: Array items select definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["select"] }, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "enum": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "enumTitles": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["type", "enum"] + "type": { "enum": ["string"] }, + "enum": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "enumTitles": { + "type": "array", + "items": { "type": "string" } } }, - "required": ["items"] + "required": ["type", "enum"] }, - "keyValueProperty": { - "title": "Utils KeyValue property", + "arrayItemsKeyValue": { + "title": "Utils: Array items keyValue definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["keyValue"] }, - "items": { + "type": { "enum": ["object"] }, + "properties": { "type": "object", - "additionalProperties": false, "properties": { - "type": { "enum": ["object"] }, - "properties": { + "key": { "type": "object", + "additionalProperties": false, "properties": { - "key": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "value": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - } + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } }, - "required": ["key", "value"], - "additionalProperties": false, - "minProperties": 1 - }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true + "required": ["type"] }, - "additionalProperties": { "type": "boolean" } + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + } }, - "required": ["type", "properties"] - } - } + "required": ["key", "value"], + "additionalProperties": false + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type", "properties"] + }, + "arrayItemsStringList": { + "title": "Utils: Array items stringList definition", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] }, - "stringListProperty": { - "title": "Utils String List property", + "arrayItemsGlobs": { + "title": "Utils: Array items globs definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["stringList"] }, - "items": { + "type": { "enum": ["object"] }, + "properties": { "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - } - } + "glob": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] }, - "globsProperty": { - "title": "Utils Globs property", + "arrayItemsPseudoUrls": { + "title": "Utils: Array items pseudoUrls definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["globs"] }, - "items": { + "type": { "enum": ["object"] }, + "properties": { "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["object"] }, - "properties": { + "purl": { "type": "object", "additionalProperties": false, "properties": { - "glob": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "method": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "payload": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "userData": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - }, - "headers": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - } - } + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] }, - "additionalProperties": { "type": "boolean" } - }, - "required": ["type"] - } - } + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] }, - "pseudoUrlsProperty": { - "title": "Utils Pseudo URLs property", + "arrayItemsRequestListSources": { + "title": "Utils: Array items requestListSources definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["pseudoUrls"] }, - "items": { + "type": { "enum": ["object"] }, + "properties": { "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["object"] }, - "properties": { + "url": { "type": "object", "additionalProperties": false, "properties": { - "purl": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "method": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "payload": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "userData": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - }, - "headers": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - } - } + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true + "method": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] }, - "additionalProperties": { "type": "boolean" } - }, - "required": ["type"] + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } + }, + "required": ["type"] + }, + "userData": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "properties": { "type": "object" } + }, + "required": ["type"] + } + } + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { "type": "boolean" } + }, + "required": ["type"] + }, + "subObjectProperties": { + "title": "Utils: Sub-object properties definition", + "type": "object", + "patternProperties": { + "^": { + "oneOf": [ + { "$ref": "#/definitions/subSchemaStringProperty" }, + { "$ref": "#/definitions/subSchemaStringEnumProperty" }, + { "$ref": "#/definitions/subSchemaArrayProperty" }, + { "$ref": "#/definitions/subSchemaObjectProperty" }, + { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaBooleanProperty" }, + { "$ref": "#/definitions/subSchemaResourceProperty" }, + { "$ref": "#/definitions/subSchemaResourceArrayProperty" } + ] } - } + }, + "additionalProperties": false }, - "requestListSourcesProperty": { - "title": "Utils Request List Sources property", + "subObjectPropertiesProxy": { + "title": "Utils: Sub-object properties proxy definition", "type": "object", + "additionalProperties": false, "properties": { - "editor": { "enum": ["requestListSources"] }, - "items": { + "useApifyProxy": { "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object", + "type": { "enum": ["boolean"] }, + "title": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["type"] + }, + "apifyProxyGroups": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "items": { + "type": ["object"], + "properties": { + "type": { "enum": ["string"] } + }, "additionalProperties": false, + "required": ["type"] + } + }, + "required": ["type"] + }, + "proxyUrls": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "items": { + "type": ["object"], "properties": { - "url": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "method": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "payload": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "pattern": { "type": "string" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" } - }, - "required": ["type"] - }, - "userData": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - }, - "headers": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["object"] }, - "properties": { - "type": "object" - } - }, - "required": ["type"] - } - } - }, - "required": { - "type": "array", - "minItems": 0, - "items": { "type": "string" }, - "uniqueItems": true - }, - "additionalProperties": { "type": "boolean" } + "type": { "enum": ["string"] } + }, + "additionalProperties": false, + "required": ["type"] + } + }, + "required": ["type"] + }, + "apifyProxyCountry": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" } }, "required": ["type"] } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index b2894a7e..af54c772 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -1,4 +1,4 @@ -import Ajv from 'ajv'; +import Ajv from 'ajv/dist/2019'; import { validateInputSchema } from '@apify/input_schema'; @@ -554,25 +554,22 @@ describe('input_schema.json', () => { description: 'Description', editor: 'schemaBased', additionalProperties: false, - // required: ['key2'], properties: { key: { type: 'object', title: 'Key', description: 'Key description', - editor: 'schemaBased', + editor: 'json', properties: { key1: { type: 'string', title: 'Key 1', description: 'Key 1 description', - editor: 'textfield', }, key2: { type: 'string', title: 'Key 2', description: 'Key 2 description', - editor: 'textfield', }, }, }, @@ -602,19 +599,17 @@ describe('input_schema.json', () => { type: 'object', title: 'Key', description: 'Key description', - editor: 'schemaBased', + editor: 'json', properties: { key1: { type: 'string', title: 'Key 1', description: 'Key 1 description', - editor: 'textfield', }, key2: { type: 'string', title: 'Key 2', description: 'Key 2 description', - editor: 'textfield', }, }, }, @@ -647,19 +642,17 @@ describe('input_schema.json', () => { type: 'object', title: 'Key', description: 'Key description', - editor: 'schemaBased', + editor: 'json', properties: { key1: { type: 'string', title: 'Key 1', description: 'Key 1 description', - editor: 'textfield', }, key2: { type: 'string', title: 'Key 2', description: 'Key 2 description', - editor: 'textfield', }, }, }, diff --git a/test/input_schema_definition.test.ts b/test/input_schema_definition.test.ts index 64d60fc2..a38b8d99 100644 --- a/test/input_schema_definition.test.ts +++ b/test/input_schema_definition.test.ts @@ -1,4 +1,4 @@ -import Ajv from 'ajv'; +import Ajv from 'ajv/dist/2019'; import { inputSchema } from '@apify/input_schema'; @@ -479,5 +479,157 @@ describe('input_schema.json', () => { })).toBe(true); }); }); + + describe('sub-schema restrictions based on editor', () => { + it('should allow only specific properties for proxy editor', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + proxy: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'proxy', + properties: { + unknownProperty: { + type: 'string', + }, + }, + }, + }, + })).toBe(false); + + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + proxy: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'proxy', + properties: { + useApifyProxy: { + type: 'boolean', + }, + apifyProxyGroups: { + type: 'array', + items: { + type: 'string', + }, + }, + proxyUrls: { + type: 'array', + items: { + type: 'string', + }, + }, + apifyProxyCountry: { + type: 'string', + }, + }, + }, + }, + })).toBe(true); + }); + + it('should allow only specific properties for keyValue array editor', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + keyValueArray: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'keyValue', + items: { + type: 'object', + properties: { + key: { + type: 'string', + title: 'Key', + description: 'The key of the key-value pair', + }, + value: { + type: 'string', + title: 'Value', + description: 'The value of the key-value pair', + }, + }, + required: ['key', 'value'], + }, + }, + }, + })).toBe(true); + + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + keyValueArray: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'keyValueArray', + items: { + type: 'object', + properties: { + key: { + type: 'string', + title: 'Key', + description: 'The key of the key-value pair', + }, + value: { + type: 'string', + title: 'Value', + description: 'The value of the key-value pair', + }, + extraProperty: { + type: 'string', + title: 'Extra Property', + description: 'This property should not be allowed', + }, + }, + required: ['key', 'value'], + }, + }, + }, + })).toBe(false); + }); + + it('should allow any properties for json editor', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + jsonField: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'json', + properties: { + anyProperty: { + type: 'string', + title: 'Field title', + description: 'My test field', + }, + anotherProperty: { + type: 'integer', + title: 'Another field title', + description: 'Another test field', + }, + }, + }, + }, + })).toBe(true); + }); + }); }); }); diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 7bd888ec..a2af35b0 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1,6 +1,6 @@ import { createPublicKey } from 'node:crypto'; -import Ajv from 'ajv'; +import Ajv from 'ajv/dist/2019'; import brokenClone from 'clone-deep'; import _ from 'underscore'; From 14c0cafe49db663099ea8ccd40e64e02e0ca1e23 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Wed, 9 Jul 2025 21:42:20 +0200 Subject: [PATCH 6/9] cleanup schema, additional tests, proper validation --- packages/input_schema/src/input_schema.ts | 43 ++++-- packages/input_schema/src/schema.json | 162 ++++++++++++++------- test/input_schema.test.ts | 169 ++++++++++++++++++++++ test/input_schema_definition.test.ts | 152 ------------------- 4 files changed, 304 insertions(+), 222 deletions(-) diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index 8351d125..79cd1bf5 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -38,13 +38,11 @@ export function parseAjvError( let fieldKey: string; let message: string; + // remove leading and trailing slashes and replace remaining slashes with dots const cleanPropertyName = (name: string) => { - // remove leading and trailing slashes and replace remaining slashes with dots return name.replace(/^\/|\/$/g, '').replace(/\//g, '.'); }; - console.log(error); - // 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') { @@ -102,22 +100,30 @@ function validateBasicStructure(validator: Ajv, obj: Record): a /** * Validates particular field against it's schema. + * @param validator An instance of AJV validator (must support draft 2019-09). + * @param fieldSchema Schema of the field to validate. + * @param fieldKey Key of the field in the input schema. + * @param subField If true, the field is a sub-field of another field, so we need to skip some definitions. */ function validateField(validator: Ajv, fieldSchema: Record, fieldKey: string, subField = false): asserts fieldSchema is FieldDefinition { const matchingDefinitions = Object .values(definitions) // cast as any, as the code in first branch seems to be invalid .filter((definition) => { + // Because the definitions contains not only the root properties definitions, but also sub-schema definitions + // and utility definitions, we need to filter them out and validate only against the appropriate ones. + // We do this by checking prefix of the definition title (Utils: or Sub-schema:) + if (definition.title.startsWith('Utils:')) { // Utility definitions are not used for property validation. - // They are used for their internal logic. + // They are used for their internal logic. Filter them out. return false; } if (!subField && definition.title.startsWith('Sub-schema:')) { - // This is a sub-schema definition, so we skip it. + // This is a sub-schema definition, but we are validating a root field, so we skip it. return false; } - if (subField && !definition.title.startsWith('Sub-schema')) { - // This is a normal definition, so we skip it. + if (subField && !definition.title.startsWith('Sub-schema:')) { + // This is a normal definition, but we are validating a sub-field, so we skip it. return false; } @@ -134,14 +140,15 @@ function validateField(validator: Ajv, fieldSchema: Record, fie throw new Error(`Input schema is not valid (${errorMessage})`); } - // When validating against schema of one definition, the definition can reference other definitions. - // So we need to add all of them to the schema. - function enhanceDefinition(definition: any) { + // We are validating a field schema against one of the definitions, but one definition can reference other definitions. + // So this basically creates a new JSON Schema with a picked definition at root and puts all definitions from the `schema.json` + // into the `definitions` property of this final schema. + const enhanceDefinition = (definition: object) => { return { ...definition, definitions, }; - } + }; // If there is only one matching then we are done and simply compare it. if (matchingDefinitions.length === 1) { @@ -171,10 +178,18 @@ function validateField(validator: Ajv, fieldSchema: Record, fie validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(definition), `schema.properties.${fieldKey}`); } +/** + * Validates all subfields (and their subfields) of a given field schema. + */ function validateSubFields(validator: Ajv, fieldSchema: InputSchemaBaseChecked, fieldKey: string) { - Object.entries(fieldSchema.properties).forEach(([subFieldKey, subFieldSchema]) => ( - validateField(validator, subFieldSchema, `${fieldKey}.${subFieldKey}`, true)), - ); + Object.entries(fieldSchema.properties).forEach(([subFieldKey, subFieldSchema]) => { + // The sub-properties has to be validated first, so we got more relevant error messages. + if ((subFieldSchema as any).properties) { + // If the field has sub-fields, we need to validate them as well. + validateSubFields(validator, subFieldSchema as any as InputSchemaBaseChecked, `${fieldKey}.${subFieldKey}`); + } + validateField(validator, subFieldSchema, `${fieldKey}.${subFieldKey}`, true); + }); } /** diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index fc1c2bc1..d522cd27 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -198,34 +198,59 @@ }, "unevaluatedProperties": false, "oneOf": [ - { "properties": { - "editor": { "enum": ["select"] }, - "items": { "$ref": "#/definitions/arrayItemsSelect" } - }}, - { "properties": { - "editor": { "enum": ["keyValue"] }, - "items": { "$ref": "#/definitions/arrayItemsKeyValue" } - }}, - { "properties": { - "editor": { "enum": ["stringList"] }, - "items": { "$ref": "#/definitions/arrayItemsStringList" } - }}, - { "properties": { - "editor": { "enum": ["globs"] }, - "items": { "$ref": "#/definitions/arrayItemsGlobs" } - }}, - { "properties": { - "editor": { "enum": ["pseudoUrls"] }, - "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } - }}, - { "properties": { - "editor": { "enum": ["requestListSources"] }, - "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } - }}, - { "properties": { - "editor": { "enum": ["json", "schemaBased", "hidden"] }, - "items": { "$ref": "#/definitions/arrayItems" } - }} + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["select"] }, + "items": { "$ref": "#/definitions/arrayItemsSelect" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["keyValue"] }, + "items": { "$ref": "#/definitions/arrayItemsKeyValue" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["stringList"] }, + "items": { "$ref": "#/definitions/arrayItemsStringList" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["globs"] }, + "items": { "$ref": "#/definitions/arrayItemsGlobs" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["pseudoUrls"] }, + "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["requestListSources"] }, + "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { "$ref": "#/definitions/arrayItems" } + } + }, + { + "not": { "required": ["editor"] }, + "properties": { "items": { "$ref": "#/definitions/arrayItems" } } + } ] }, "else": { @@ -585,34 +610,59 @@ "required": ["type", "title", "description"], "unevaluatedProperties": false, "oneOf": [ - { "properties": { - "editor": { "enum": ["select"] }, - "items": { "$ref": "#/definitions/arrayItemsSelect" } - }}, - { "properties": { - "editor": { "enum": ["keyValue"] }, - "items": { "$ref": "#/definitions/arrayItemsKeyValue" } - }}, - { "properties": { - "editor": { "enum": ["stringList"] }, - "items": { "$ref": "#/definitions/arrayItemsStringList" } - }}, - { "properties": { - "editor": { "enum": ["globs"] }, - "items": { "$ref": "#/definitions/arrayItemsGlobs" } - }}, - { "properties": { - "editor": { "enum": ["pseudoUrls"] }, - "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } - }}, - { "properties": { - "editor": { "enum": ["requestListSources"] }, - "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } - }}, - { "properties": { - "editor": { "enum": ["json", "schemaBased", "hidden"] }, - "items": { "$ref": "#/definitions/arrayItems" } - }} + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["select"] }, + "items": { "$ref": "#/definitions/arrayItemsSelect" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["keyValue"] }, + "items": { "$ref": "#/definitions/arrayItemsKeyValue" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["stringList"] }, + "items": { "$ref": "#/definitions/arrayItemsStringList" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["globs"] }, + "items": { "$ref": "#/definitions/arrayItemsGlobs" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["pseudoUrls"] }, + "items": { "$ref": "#/definitions/arrayItemsPseudoUrls" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["requestListSources"] }, + "items": { "$ref": "#/definitions/arrayItemsRequestListSources" } + } + }, + { + "required": ["editor"], + "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "items": { "$ref": "#/definitions/arrayItems" } + } + }, + { + "not": { "required": ["editor"] }, + "properties": { "items": { "$ref": "#/definitions/arrayItems" } } + } ] }, "subSchemaIntegerProperty": { diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index af54c772..a37229dc 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -666,5 +666,174 @@ describe('input_schema.json', () => { expect(() => validateInputSchema(validator, schema)).not.toThrow(); }); }); + + describe('sub-schema restrictions based on editor', () => { + it('should not allow unknown properties for proxy editor', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + proxy: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'proxy', + properties: { + unknownProperty: { + type: 'string', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow(); + }); + + it('should allow only specific properties for proxy editor', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + proxy: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'proxy', + properties: { + useApifyProxy: { + type: 'boolean', + title: 'Use Apify Proxy', + description: 'Whether to use Apify Proxy or not', + }, + apifyProxyGroups: { + type: 'array', + title: 'Apify Proxy Groups', + description: 'Apify Proxy groups to use', + items: { + type: 'string', + }, + }, + proxyUrls: { + type: 'array', + title: 'Custom Proxy URLs', + description: 'Custom proxy URLs to use', + items: { + type: 'string', + }, + }, + apifyProxyCountry: { + type: 'string', + title: 'Apify Proxy Country', + description: 'Country code for Apify Proxy', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should not allow unknown properties for keyValue array editor', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + keyValueArray: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'keyValueArray', + items: { + type: 'object', + properties: { + key: { + type: 'string', + title: 'Key', + description: 'The key of the key-value pair', + }, + value: { + type: 'string', + title: 'Value', + description: 'The value of the key-value pair', + }, + extraProperty: { + type: 'string', + title: 'Extra Property', + description: 'This property should not be allowed', + }, + }, + required: ['key', 'value'], + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).toThrow(); + }); + + it('should allow only specific properties for keyValue array editor', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + keyValueArray: { + title: 'Field title', + description: 'My test field', + type: 'array', + editor: 'keyValue', + items: { + type: 'object', + properties: { + key: { + type: 'string', + title: 'Key', + description: 'The key of the key-value pair', + }, + value: { + type: 'string', + title: 'Value', + description: 'The value of the key-value pair', + }, + }, + required: ['key', 'value'], + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + + it('should allow any properties for json editor', () => { + const schema = { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + jsonField: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'json', + properties: { + anyProperty: { + type: 'string', + title: 'Field title', + description: 'My test field', + }, + anotherProperty: { + type: 'integer', + title: 'Another field title', + description: 'Another test field', + }, + }, + }, + }, + }; + expect(() => validateInputSchema(validator, schema)).not.toThrow(); + }); + }); }); }); diff --git a/test/input_schema_definition.test.ts b/test/input_schema_definition.test.ts index a38b8d99..be32e4fc 100644 --- a/test/input_schema_definition.test.ts +++ b/test/input_schema_definition.test.ts @@ -479,157 +479,5 @@ describe('input_schema.json', () => { })).toBe(true); }); }); - - describe('sub-schema restrictions based on editor', () => { - it('should allow only specific properties for proxy editor', () => { - expect(ajv.validate(inputSchema, { - title: 'Test input schema', - type: 'object', - schemaVersion: 1, - properties: { - proxy: { - title: 'Field title', - description: 'My test field', - type: 'object', - editor: 'proxy', - properties: { - unknownProperty: { - type: 'string', - }, - }, - }, - }, - })).toBe(false); - - expect(ajv.validate(inputSchema, { - title: 'Test input schema', - type: 'object', - schemaVersion: 1, - properties: { - proxy: { - title: 'Field title', - description: 'My test field', - type: 'object', - editor: 'proxy', - properties: { - useApifyProxy: { - type: 'boolean', - }, - apifyProxyGroups: { - type: 'array', - items: { - type: 'string', - }, - }, - proxyUrls: { - type: 'array', - items: { - type: 'string', - }, - }, - apifyProxyCountry: { - type: 'string', - }, - }, - }, - }, - })).toBe(true); - }); - - it('should allow only specific properties for keyValue array editor', () => { - expect(ajv.validate(inputSchema, { - title: 'Test input schema', - type: 'object', - schemaVersion: 1, - properties: { - keyValueArray: { - title: 'Field title', - description: 'My test field', - type: 'array', - editor: 'keyValue', - items: { - type: 'object', - properties: { - key: { - type: 'string', - title: 'Key', - description: 'The key of the key-value pair', - }, - value: { - type: 'string', - title: 'Value', - description: 'The value of the key-value pair', - }, - }, - required: ['key', 'value'], - }, - }, - }, - })).toBe(true); - - expect(ajv.validate(inputSchema, { - title: 'Test input schema', - type: 'object', - schemaVersion: 1, - properties: { - keyValueArray: { - title: 'Field title', - description: 'My test field', - type: 'array', - editor: 'keyValueArray', - items: { - type: 'object', - properties: { - key: { - type: 'string', - title: 'Key', - description: 'The key of the key-value pair', - }, - value: { - type: 'string', - title: 'Value', - description: 'The value of the key-value pair', - }, - extraProperty: { - type: 'string', - title: 'Extra Property', - description: 'This property should not be allowed', - }, - }, - required: ['key', 'value'], - }, - }, - }, - })).toBe(false); - }); - - it('should allow any properties for json editor', () => { - expect(ajv.validate(inputSchema, { - title: 'Test input schema', - type: 'object', - schemaVersion: 1, - properties: { - jsonField: { - title: 'Field title', - description: 'My test field', - type: 'object', - editor: 'json', - properties: { - anyProperty: { - type: 'string', - title: 'Field title', - description: 'My test field', - }, - anotherProperty: { - type: 'integer', - title: 'Another field title', - description: 'Another test field', - }, - }, - }, - }, - })).toBe(true); - }); - }); }); }); From 686e588e9ed363f91507607bb9f6a460d04517c8 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Wed, 9 Jul 2025 21:46:45 +0200 Subject: [PATCH 7/9] input validate still with draft 07 --- test/utilities.client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index a2af35b0..7bd888ec 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1,6 +1,6 @@ import { createPublicKey } from 'node:crypto'; -import Ajv from 'ajv/dist/2019'; +import Ajv from 'ajv'; import brokenClone from 'clone-deep'; import _ from 'underscore'; From 0398ea2042db90772d69d35a3eeb267883f19560 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Tue, 15 Jul 2025 14:01:57 +0200 Subject: [PATCH 8/9] address CR --- packages/input_schema/src/input_schema.ts | 81 ++++++++++++++++------- packages/input_schema/src/utilities.ts | 12 ++++ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index 79cd1bf5..ceac8ab1 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -10,11 +10,33 @@ import type { InputSchemaBaseChecked, StringFieldDefinition, } from './types'; +import { ensureAjvSupportsDraft2019 } from './utilities'; export { schema as inputSchema }; const { definitions } = schema; +// Because the definitions contain not only the root properties definitions, but also sub-schema definitions +// and utility definitions, we need to filter them out and validate only against the appropriate ones. +// We do this by checking the prefix of the definition title (Utils: or Sub-schema:) + +const [fieldDefinitions, subFieldDefinitions] = Object + .values(definitions) + .reduce<[any[], any[]]>((acc, definition) => { + if (definition.title.startsWith('Utils:')) { + // skip utility definitions + return acc; + } + + if (definition.title.startsWith('Sub-schema:')) { + acc[1].push(definition); + } else { + acc[0].push(definition); + } + + return acc; + }, [[], []]); + /** * This function parses AJV error and transforms it into a readable string. * @@ -103,30 +125,14 @@ function validateBasicStructure(validator: Ajv, obj: Record): a * @param validator An instance of AJV validator (must support draft 2019-09). * @param fieldSchema Schema of the field to validate. * @param fieldKey Key of the field in the input schema. - * @param subField If true, the field is a sub-field of another field, so we need to skip some definitions. + * @param isSubField If true, the field is a sub-field of another field, so we need to skip some definitions. */ -function validateField(validator: Ajv, fieldSchema: Record, fieldKey: string, subField = false): asserts fieldSchema is FieldDefinition { +function validateField(validator: Ajv, fieldSchema: Record, fieldKey: string, isSubField = false): asserts fieldSchema is FieldDefinition { + const relevantDefinitions = isSubField ? subFieldDefinitions : fieldDefinitions; + const matchingDefinitions = Object - .values(definitions) // cast as any, as the code in first branch seems to be invalid + .values(relevantDefinitions) // cast as any, as the code in first branch seems to be invalid .filter((definition) => { - // Because the definitions contains not only the root properties definitions, but also sub-schema definitions - // and utility definitions, we need to filter them out and validate only against the appropriate ones. - // We do this by checking prefix of the definition title (Utils: or Sub-schema:) - - if (definition.title.startsWith('Utils:')) { - // Utility definitions are not used for property validation. - // They are used for their internal logic. Filter them out. - return false; - } - if (!subField && definition.title.startsWith('Sub-schema:')) { - // This is a sub-schema definition, but we are validating a root field, so we skip it. - return false; - } - if (subField && !definition.title.startsWith('Sub-schema:')) { - // This is a normal definition, but we are validating a sub-field, so we skip it. - return false; - } - return definition.properties.type.enum // This is a normal case where fieldSchema.type can be only one possible value matching definition.properties.type.enum.0 ? definition.properties.type.enum[0] === fieldSchema.type @@ -184,24 +190,51 @@ function validateField(validator: Ajv, fieldSchema: Record, fie function validateSubFields(validator: Ajv, fieldSchema: InputSchemaBaseChecked, fieldKey: string) { Object.entries(fieldSchema.properties).forEach(([subFieldKey, subFieldSchema]) => { // The sub-properties has to be validated first, so we got more relevant error messages. - if ((subFieldSchema as any).properties) { + if (subFieldSchema.type === 'object' && subFieldSchema.properties) { // If the field has sub-fields, we need to validate them as well. validateSubFields(validator, subFieldSchema as any as InputSchemaBaseChecked, `${fieldKey}.${subFieldKey}`); } + + // If the field is an array and has defined schema (items property), we need to validate it differently. + if (subFieldSchema.type === 'array' && subFieldSchema.items) { + validateArrayField(validator, subFieldSchema, `${fieldKey}.${subFieldKey}`); + } + validateField(validator, subFieldSchema, `${fieldKey}.${subFieldKey}`, true); }); } +function validateArrayField(validator: Ajv, fieldSchema: { items?: { type: 'string', properties: Record }}, fieldKey: string) { + const arraySchema = (fieldSchema as any).items; + if (!arraySchema) return; + + // If the array has object items and have sub-schema defined, we need to validate it. + if (arraySchema.type === 'object' && arraySchema.properties) { + validateSubFields(validator, arraySchema as InputSchemaBaseChecked, `${fieldKey}.items`); + } + + // If it's an array of arrays we need, we need to validate the inner array schema. + if (arraySchema.type === 'array' && arraySchema.items) { + validateArrayField(validator, arraySchema, `${fieldKey}.items`); + } +} + /** * Validates all properties in the input schema */ function validateProperties(inputSchema: InputSchemaBaseChecked, validator: Ajv): asserts inputSchema is InputSchema { Object.entries(inputSchema.properties).forEach(([fieldKey, fieldSchema]) => { // The sub-properties has to be validated first, so we got more relevant error messages. - if ((fieldSchema as any).properties) { + if (fieldSchema.type === 'object' && fieldSchema.properties) { // If the field has sub-fields, we need to validate them as well. validateSubFields(validator, fieldSchema as any as InputSchemaBaseChecked, fieldKey); } + + // If the field is an array and has defined schema (items property), we need to validate it differently. + if (fieldSchema.type === 'array' && fieldSchema.items) { + validateArrayField(validator, fieldSchema, fieldKey); + } + validateField(validator, fieldSchema, fieldKey); }); } @@ -233,6 +266,8 @@ export function validateExistenceOfRequiredFields(inputSchema: InputSchema) { * @param inputSchema Input schema to validate. */ export function validateInputSchema(validator: Ajv, inputSchema: Record): asserts inputSchema is InputSchema { + ensureAjvSupportsDraft2019(validator); + // First validate just basic structure without fields. validateBasicStructure(validator, inputSchema); diff --git a/packages/input_schema/src/utilities.ts b/packages/input_schema/src/utilities.ts index b573b5ba..8fbaa873 100644 --- a/packages/input_schema/src/utilities.ts +++ b/packages/input_schema/src/utilities.ts @@ -1,5 +1,6 @@ import { parse } from 'acorn-loose'; import type { ValidateFunction } from 'ajv'; +import type Ajv from 'ajv/dist/2019'; import { countries } from 'countries-list'; import { PROXY_URL_REGEX, URL_REGEX } from '@apify/consts'; @@ -349,3 +350,14 @@ export function makeInputJsFieldsReadable(json: string, jsFields: string[], json return niceJson; } + +const DRAFT_2019_09_META_SCHEMA = 'https://json-schema.org/draft/2019-09/schema'; + +export function ensureAjvSupportsDraft2019(ajvInstance: Ajv) { + const metaSchema = ajvInstance.getSchema(DRAFT_2019_09_META_SCHEMA); + if (!metaSchema) { + throw new Error( + `The provided Ajv instance does not support draft-2019-09 (missing meta-schema ${DRAFT_2019_09_META_SCHEMA}).`, + ); + } +} From 37c58d391abe115745c83ebf637ceea92cc674b3 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Tue, 15 Jul 2025 14:20:48 +0200 Subject: [PATCH 9/9] update and add tests --- test/utilities.client.test.ts | 94 ++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 7bd888ec..cff1f056 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1298,23 +1298,31 @@ describe('utilities.client', () => { required: ['key1'], }, }); - const inputs = [ - // 4 invalid inputs + const validInputs = [ + { field: { key1: 'value' } }, + { field: { key1: 'value', key2: 'value' } }, + ]; + const invalidInputs = [ { field: [] }, { field: {} }, { field: { key2: 'value' } }, { field: { key3: 'value' } }, - // 2 valid inputs - { field: { key1: 'value' } }, - { field: { key1: 'value', key2: 'value' } }, ]; - const results = inputs + 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(4); - // There should be 4 invalid inputs - expect(results.length).toEqual(4); + expect(errorResults[0][0].message).toEqual('Field input.field must be object'); + expect(errorResults[1][0].message).toEqual('Field input.field.key1 is required'); + expect(errorResults[2][0].message).toEqual('Field input.field.key1 is required'); + expect(errorResults[3][0].message).toEqual('Field input.field.key1 is required'); }); it('should allow sub-schema for array property', () => { @@ -1345,23 +1353,75 @@ describe('utilities.client', () => { }, }, }); - const inputs = [ - // 3 invalid inputs - { field: {} }, - { field: [{ key2: 'value' }] }, - { field: [{ key3: 'value' }] }, - // 2 valid inputs + const validInputs = [ { field: [{ key1: 'value' }] }, { field: [{ key1: 'value' }, { key1: 'value' }] }, { field: [{ key1: 'value', key2: 'value' }, { key1: 'value' }] }, ]; + const invalidInputs = [ + { field: {} }, + { field: [{ key2: 'value' }] }, + { field: [{ key3: 'value' }] }, + ]; - const results = inputs + 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(3); + + expect(errorResults[0][0].message).toEqual('Field input.field must be array'); + expect(errorResults[1][0].message).toEqual('Field input.field.0.key1 is required'); + expect(errorResults[2][0].message).toEqual('Field input.field.0.key1 is required'); + }); + + it('dot in property names should be allowed', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'object', + editor: 'schemaBased', + properties: { + 'key.with.dot': { + type: 'string', + title: 'Key with dot', + description: 'Description for key with dot', + editor: 'textfield', + }, + }, + additionalProperties: false, + required: ['key.with.dot'], + }, + }); + const validInputs = [ + { field: { 'key.with.dot': 'value' } }, + ]; + const invalidInputs = [ + { field: [] }, + { field: {} }, + { field: { 'key.with.dot2': 'value' } }, + { field: { key: { with: { dot: 'value' } } } }, + ]; + + 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(4); - // There should be 4 invalid inputs - expect(results.length).toEqual(3); + expect(errorResults[0][0].message).toEqual('Field input.field must be object'); + expect(errorResults[1][0].message).toEqual('Field input.field.key.with.dot is required'); + expect(errorResults[2][0].message).toEqual('Field input.field.key.with.dot is required'); + expect(errorResults[2][0].message).toEqual('Field input.field.key.with.dot is required'); }); }); });