diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index f1929f13..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. * @@ -38,6 +60,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) => { + 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 +75,23 @@ 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 === 'unevaluatedProperties') { + fieldKey = cleanPropertyName(`${error.instancePath}/${error.params.unevaluatedProperty}`); 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 }); } @@ -92,10 +122,16 @@ 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 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): 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) => { 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 @@ -110,9 +146,19 @@ function validateField(validator: Ajv, fieldSchema: Record, fie throw new Error(`Input schema is not valid (${errorMessage})`); } + // 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) { - validateAgainstSchemaOrThrow(validator, fieldSchema, matchingDefinitions[0], `schema.properties.${fieldKey}`); + validateAgainstSchemaOrThrow(validator, fieldSchema, enhanceDefinition(matchingDefinitions[0]), `schema.properties.${fieldKey}`); return; } @@ -121,30 +167,76 @@ 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}`); +} + +/** + * 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]) => { + // The sub-properties has to be validated first, so we got more relevant error messages. + 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]) => ( - 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.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); + }); } /** @@ -168,8 +260,14 @@ 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 { + ensureAjvSupportsDraft2019(validator); + // First validate just basic structure without fields. validateBasicStructure(validator, inputSchema); diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index 2343a9ba..d522cd27 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -167,86 +167,91 @@ "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"] }, "isSecret": { "type": "boolean" } }, - "additionalProperties": true, "required": ["type", "title", "description", "editor"], "if": { "properties": { - "isSecret": { - "not": { - "const": true - } - } + "isSecret": { "not": { "const": true } } } }, "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", "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" }, - "isSecret": { "enum": [false] } + "unevaluatedProperties": false, + "oneOf": [ + { + "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": { "additionalProperties": false, @@ -263,6 +268,7 @@ "uniqueItems": { "type": "boolean" }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, + "items": { "$ref": "#/definitions/arrayItems" }, "isSecret": { "enum": [true] } } } @@ -270,26 +276,20 @@ "objectProperty": { "title": "Object property", "type": "object", - "additionalProperties": true, "properties": { "type": { "enum": ["object"] }, "title": { "type": "string" }, "description": { "type": "string" }, - "editor": { "enum": ["json", "proxy", "hidden"] }, + "editor": { "enum": ["json", "proxy", "schemaBased", "hidden"] }, "isSecret": { "type": "boolean" } }, "required": ["type", "title", "description", "editor"], "if": { "properties": { - "isSecret": { - "not": { - "const": true - } - } + "isSecret": { "not": { "const": true } } } }, "then": { - "additionalProperties": false, "properties": { "type": { "enum": ["object"] }, "title": { "type": "string" }, @@ -302,11 +302,31 @@ "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" }, - "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": { "$ref": "#/definitions/subObjectPropertiesProxy" } + }}, + { "properties": { + "editor": { "enum": ["json", "schemaBased", "hidden"] }, + "properties": { "$ref": "#/definitions/subObjectProperties" } + }} + ] }, "else": { "additionalProperties": false, @@ -324,7 +344,17 @@ "editor": { "enum": ["json", "hidden"] }, "sectionCaption": { "type": "string" }, "sectionDescription": { "type": "string" }, - "isSecret": { "enum": [true] } + "properties": { "$ref": "#/definitions/subObjectProperties" }, + "isSecret": { "enum": [true] }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } } } }, @@ -490,6 +520,742 @@ "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"], + "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"] }, + "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" } + }, + "required": ["type", "title", "description"], + "unevaluatedProperties": false, + "oneOf": [ + { + "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": { + "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", "hidden"] }, + "properties": { "$ref": "#/definitions/subObjectProperties" }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } + }, + "required": ["type", "title", "description"] + }, + "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"] + }, + "arrayItems": { + "title": "Utils: Array items definition", + "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 + }, + "required": { + "type": "array", + "minItems": 0, + "items": { "type": "string" }, + "uniqueItems": true + }, + "additionalProperties": { + "type": "boolean" + } + } + }, + "else": { + "if": { + "properties": { "type": { "const": "array" } } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["array"] }, + "items": { + "$ref": "#/definitions/arrayItems" + } + } + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string", "integer", "boolean"] } + } + } + } + }, + "arrayItemsSelect": { + "title": "Utils: Array items select definition", + "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"] + }, + "arrayItemsKeyValue": { + "title": "Utils: Array items keyValue definition", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "properties": { + "key": { + "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"] + }, + "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": ["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"] + }, + "arrayItemsGlobs": { + "title": "Utils: Array items globs definition", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "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"] + }, + "arrayItemsPseudoUrls": { + "title": "Utils: Array items pseudoUrls definition", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "purl": { + "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"] + }, + "arrayItemsRequestListSources": { + "title": "Utils: Array items requestListSources definition", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["object"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "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"] + }, + "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 + }, + "subObjectPropertiesProxy": { + "title": "Utils: Sub-object properties proxy definition", + "type": "object", + "additionalProperties": false, + "properties": { + "useApifyProxy": { + "type": "object", + "additionalProperties": false, + "properties": { + "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": { + "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/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}).`, + ); + } +} diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 4cf8b599..a37229dc 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'; @@ -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")', ); }); @@ -538,5 +540,300 @@ 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, + properties: { + key: { + type: 'object', + title: 'Key', + description: 'Key description', + editor: 'json', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + }, + }, + }, + }, + }, + }, + }; + + 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: 'json', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + }, + }, + }, + }, + }, + }, + }, + }; + + 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: 'json', + properties: { + key1: { + type: 'string', + title: 'Key 1', + description: 'Key 1 description', + }, + key2: { + type: 'string', + title: 'Key 2', + description: 'Key 2 description', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + 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 64d60fc2..be32e4fc 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'; diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 354861a0..cff1f056 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1271,6 +1271,159 @@ describe('utilities.client', () => { expect(errors[0].message).toEqual('The field schema.properties.field is a secret field, but its schema has changed. Please update the value in the input editor.'); }); }); + + 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 validInputs = [ + { field: { key1: 'value' } }, + { field: { key1: 'value', key2: 'value' } }, + ]; + const invalidInputs = [ + { field: [] }, + { field: {} }, + { field: { key2: 'value' } }, + { field: { key3: '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); + + 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', () => { + 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 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' }] }, + ]; + + 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); + + 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'); + }); + }); }); describe('#jsonStringifyExtended()', () => {