import _ from 'lodash' const isEventTypeInstancePath = RegExp.prototype.test.bind( /^\/functions\/[^/]+\/events\/\d+$/, ) const oneOfPathPattern = /\/(?:anyOf|oneOf)(?:\/\d+\/|$)/ const isAnyOfPathTypePostfix = RegExp.prototype.test.bind(/^\/\d+\/type$/) const oneOfKeywords = new Set(['anyOf', 'oneOf']) const filterIrreleventEventConfigurationErrors = (resultErrorsSet) => { // 1. Resolve all errors at event type configuration level const eventTypeErrors = Array.from(resultErrorsSet).filter( ({ instancePath }) => isEventTypeInstancePath(instancePath), ) // 2. Group event type configuration errors by event instance const eventTypeErrorsByEvent = _.groupBy( eventTypeErrors, ({ instancePath }) => instancePath, ) // 3. Process each error group individually for (const [instancePath, eventEventTypeErrors] of Object.entries( eventTypeErrorsByEvent, )) { // 3.1 Resolve error that signals that no event schema was matched const noMatchingEventError = eventEventTypeErrors.find(({ keyword }) => oneOfKeywords.has(keyword), ) // 3.2 Group errors by event type const eventEventTypeErrorsByTypeIndex = _.groupBy( eventEventTypeErrors, ({ schemaPath }) => { if (schemaPath === noMatchingEventError.schemaPath) return 'root' return schemaPath.slice( 0, schemaPath.indexOf('/', noMatchingEventError.schemaPath.length + 1) + 1, ) }, ) delete eventEventTypeErrorsByTypeIndex.root // 3.3 Resolve eventual type configuration errors for intended event type const eventConfiguredEventTypeErrors = Object.entries( eventEventTypeErrorsByTypeIndex, ).find(([, errors]) => errors.every(({ keyword }) => keyword !== 'required'), ) if (!eventConfiguredEventTypeErrors) { // 3.4 If there are no event type configuration errors for intended event type if ( !Array.from(resultErrorsSet).some( (error) => error.instancePath.startsWith(instancePath) && error.instancePath !== instancePath, ) ) { // 3.4.1 If there are no event configuration errors, it means it's not supported event type: // 3.4.1.1 Surface: No matching event error // 3.4.1.2 Discard: All event type configuration errors for (const error of eventEventTypeErrors) { if (error !== noMatchingEventError) resultErrorsSet.delete(error) } } else { // 3.4.2 If there are event configuration errors: // 3.4.2.1 Surface: Event configuration errors // 3.4.2.2 Discard: // - No matching event error // - All event type configuration errors produced by other event schemas for (const error of eventEventTypeErrors) resultErrorsSet.delete(error) } } else { // 3.5 There are event type configuration errors for intended event type // 3.5.1 Surface: Event type configuration errors for configured and supported event type // 3.5.2 Discard: // - No matching event error // - All event type configuration errors produced by other event schemas // - All event configuration errors const meaningfulSchemaPath = eventConfiguredEventTypeErrors[0] for (const error of resultErrorsSet) { if (!error.instancePath.startsWith(instancePath)) continue if ( error.instancePath === instancePath && error.schemaPath.startsWith(meaningfulSchemaPath) ) { continue } resultErrorsSet.delete(error) } } } } const filterIrrelevantAnyOfErrors = (resultErrorsSet) => { // 1. Group errors by anyOf/oneOf schema path const oneOfErrorsByPath = {} for (const error of resultErrorsSet) { const schemaPath = error.schemaPath let fromIndex = 0 let oneOfPathIndex = schemaPath.search(oneOfPathPattern) while (oneOfPathIndex !== -1) { const oneOfPath = schemaPath.slice( 0, fromIndex + oneOfPathIndex + 'anyOf/'.length, ) if (!oneOfErrorsByPath[oneOfPath]) oneOfErrorsByPath[oneOfPath] = [] oneOfErrorsByPath[oneOfPath].push(error) fromIndex += oneOfPathIndex + 'anyOf/'.length oneOfPathIndex = schemaPath.slice(fromIndex).search(oneOfPathPattern) } } // 2. Process resolved groups for (const [oneOfPath, oneOfPathErrors] of Object.entries( oneOfErrorsByPath, )) { // 2.1. If just one error, set was already filtered by event configuration errors filter if (oneOfPathErrors.length === 1) continue // 2.2. Group by instancePath oneOfPathErrors.sort( ({ instancePath: instancePathA }, { instancePath: instancePathB }) => instancePathA.localeCompare(instancePathB), ) let currentInstancePath = oneOfPathErrors[0].instancePath Object.values( _.groupBy(oneOfPathErrors, ({ instancePath }) => { if ( instancePath !== currentInstancePath && !instancePath.startsWith(`${currentInstancePath}/`) ) { currentInstancePath = instancePath } return currentInstancePath }), ).forEach((instancePathOneOfPathErrors) => { // 2.2.1.If just one error, set was already filtered by event configuration errors filter if (instancePathOneOfPathErrors.length === 1) return // 2.2.2 Group by anyOf variant const groupFromIndex = oneOfPath.length + 1 const instancePathOneOfPathErrorsByVariant = _.groupBy( instancePathOneOfPathErrors, ({ schemaPath }) => { if (groupFromIndex > schemaPath.length) return 'root' return schemaPath.slice( groupFromIndex, schemaPath.indexOf('/', groupFromIndex), ) }, ) // 2.2.3 If no root error, set was already filtered by event configuration errors filter if (!instancePathOneOfPathErrorsByVariant.root) return const noMatchingVariantError = instancePathOneOfPathErrorsByVariant.root[0] delete instancePathOneOfPathErrorsByVariant.root let instancePathOneOfPathVariants = Object.values( instancePathOneOfPathErrorsByVariant, ) // 2.2.4 If no variants, set was already filtered by event configuration errors filter if (!instancePathOneOfPathVariants.length) return if (instancePathOneOfPathVariants.length > 1) { // 2.2.5 If errors reported for more than one variant // 2.2.5.1 Filter variants where value type was not met instancePathOneOfPathVariants = instancePathOneOfPathVariants.filter( (instancePathOneOfPathVariantErrors) => { if (instancePathOneOfPathVariantErrors.length !== 1) return true if (instancePathOneOfPathVariantErrors[0].keyword !== 'type') return true if ( !isAnyOfPathTypePostfix( instancePathOneOfPathVariantErrors[0].schemaPath.slice( oneOfPath.length, ), ) ) { return true } for (const instancePathOneOfPathVariantError of instancePathOneOfPathVariantErrors) { resultErrorsSet.delete(instancePathOneOfPathVariantError) } return false }, ) if (instancePathOneOfPathVariants.length > 1) { // 2.2.5.2 Leave out variants where errors address deepest data paths let deepestInstancePathSize = 0 for (const instancePathOneOfPathVariantErrors of instancePathOneOfPathVariants) { instancePathOneOfPathVariantErrors.deepestInstancePathSize = Math.max( ...instancePathOneOfPathVariantErrors.map( ({ instancePath }) => (instancePath.match(/\//g) || []).length, ), ) if ( instancePathOneOfPathVariantErrors.deepestInstancePathSize > deepestInstancePathSize ) { deepestInstancePathSize = instancePathOneOfPathVariantErrors.deepestInstancePathSize } } instancePathOneOfPathVariants = instancePathOneOfPathVariants.filter( (instancePathAnyOfPathVariantErrors) => { if ( instancePathAnyOfPathVariantErrors.deepestInstancePathSize === deepestInstancePathSize ) { return true } for (const instancePathOneOfPathVariantError of instancePathAnyOfPathVariantErrors) { resultErrorsSet.delete(instancePathOneOfPathVariantError) } return false }, ) } } // 2.2.6 If all variants were filtered, expose just "no matching variant" error if (!instancePathOneOfPathVariants.length) return // 2.2.7 If just one variant left, expose only errors for that variant if (instancePathOneOfPathVariants.length === 1) { resultErrorsSet.delete(noMatchingVariantError) return } // 2.2.8 If more than one variant left, expose just "no matching variant" error for (const instancePathOneOfPathVariantErrors of instancePathOneOfPathVariants) { const types = new Set() for (const instancePathOneOfPathVariantError of instancePathOneOfPathVariantErrors) { const parentSchema = instancePathOneOfPathVariantError.parentSchema types.add(parentSchema.const ? 'const' : parentSchema.type) resultErrorsSet.delete(instancePathOneOfPathVariantError) } if (types.size === 1) noMatchingVariantError.commonType = types.values().next().value } }) } } const normalizeInstancePath = (instancePath) => { if (!instancePath) return 'root' // This code removes leading / and replaces / with . in error path indication return `'${instancePath.slice(1).replace(/\//g, '.')}'` } const improveMessages = (resultErrorsSet) => { for (const error of resultErrorsSet) { switch (error.keyword) { case 'additionalProperties': if (error.instancePath === '/functions') { error.message = `name '${error.params.additionalProperty}' must be alphanumeric` } else { error.message = `unrecognized property '${error.params.additionalProperty}'` } break case 'regexp': error.message = `value '${error.data}' does not satisfy pattern ${error.schema}` break case 'anyOf': if (isEventTypeInstancePath(error.instancePath)) { error.message = 'unsupported function event' break } // fallthrough case 'oneOf': if (error.commonType) { if (error.commonType === 'const') error.message = 'unsupported value' else error.message = `unsupported ${error.commonType} format` } else { error.message = 'unsupported configuration format' } break case 'enum': if ( error.params.allowedValues.every((value) => typeof value === 'string') ) { error.message += ` [${error.params.allowedValues.join(', ')}]` } break default: } error.message = `at ${normalizeInstancePath(error.instancePath)}: ${ error.message }` } } const filterDuplicateErrors = (resultErrorsSet) => { const seenMessages = new Set() resultErrorsSet.forEach((item) => { if (!seenMessages.has(item.message)) { seenMessages.add(item.message) } else { resultErrorsSet.delete(item) } }) } /* * For error object structure, see https://github.com/ajv-validator/ajv/#error-objects */ export default (ajvErrors) => { const resultErrorsSet = new Set(ajvErrors) // 1. Filter eventual irrelevant errors for faulty event type configurations filterIrreleventEventConfigurationErrors(resultErrorsSet) // 2. Filter eventual irrelevant errors produced by side anyOf variants filterIrrelevantAnyOfErrors(resultErrorsSet) // 3. Improve messages UX improveMessages(resultErrorsSet) // 4. Filter out errors to prevent displaying the same message more than once filterDuplicateErrors(resultErrorsSet) return Array.from(resultErrorsSet) }