|
| 1 | +import decamelize from 'decamelize' |
| 2 | +import { createLogger } from '@generates/logger' |
| 3 | +import { has } from '@generates/dotter' |
| 4 | +import { isEmpty } from './validators.js' |
| 5 | + |
| 6 | +const logger = createLogger({ level: 'info', namespace: 'nrg.validation' }) |
| 7 | + |
| 8 | +const defaults = { failFast: 0 } |
| 9 | +const pipe = (...fns) => val => fns.reduce((acc, fn) => fn(acc), val) |
| 10 | +const toValidators = (acc, [key, option]) => |
| 11 | + option?.validate && key !== 'canBeEmpty' ? acc.concat([option]) : acc |
| 12 | + |
| 13 | +export default class SchemaValidator { |
| 14 | + constructor (schema, options) { |
| 15 | + this.schema = schema |
| 16 | + this.fields = {} |
| 17 | + |
| 18 | + // Merge the given options with the defaults. |
| 19 | + this.options = Object.assign({}, defaults, options) |
| 20 | + |
| 21 | + // Convert the fields in the schema definition to objects that can be used |
| 22 | + // to validate data. |
| 23 | + for (const [field, options] of Object.entries(schema)) { |
| 24 | + const defaultName = decamelize(field, { separator: ' ' }) |
| 25 | + this.fields[field] = { |
| 26 | + ...options, |
| 27 | + name: options.name && !options.validate ? options.name : defaultName, |
| 28 | + validators: Object.entries(options).reduce(toValidators, []), |
| 29 | + modifiers: Object.values(options).filter(o => o?.modify) |
| 30 | + } |
| 31 | + |
| 32 | + // Intended for nested SchemaValidators. |
| 33 | + if (options.validate) { |
| 34 | + this.fields[field].validators.push(options) |
| 35 | + if (options.constructor?.name === 'SchemaValidator') { |
| 36 | + this.fields[field].isSchemaValidator = true |
| 37 | + } |
| 38 | + } |
| 39 | + } |
| 40 | + } |
| 41 | + |
| 42 | + handleFailure (ctx, key, field) { |
| 43 | + // Log validation failure. |
| 44 | + if (ctx.validations[key].isEmpty) { |
| 45 | + logger.debug(`Required field ${key} is empty`) |
| 46 | + } else if (ctx.validations[key].err) { |
| 47 | + logger.warn('Error during validation', ctx.validations[key].err) |
| 48 | + } else { |
| 49 | + logger.debug('Validation failure', ctx.validations[key]) |
| 50 | + } |
| 51 | + |
| 52 | + // Determine validation failure message and add it to feedback. |
| 53 | + let message = ctx.validations[key].message |
| 54 | + if (!message && field.message) { |
| 55 | + if (typeof field.message === 'function') { |
| 56 | + message = field.message(ctx, key, field) |
| 57 | + } else { |
| 58 | + message = field.message |
| 59 | + } |
| 60 | + } else if (!message) { |
| 61 | + message = `A valid ${field.name} is required.` |
| 62 | + } |
| 63 | + if (ctx.feedback[key]) { |
| 64 | + ctx.feedback[key].push(message) |
| 65 | + } else { |
| 66 | + ctx.feedback[key] = [message] |
| 67 | + } |
| 68 | + |
| 69 | + // Add any other feedback within the validation object to feedback for the |
| 70 | + // field. |
| 71 | + const { feedback } = ctx.validations[key] |
| 72 | + if (feedback) { |
| 73 | + if (Array.isArray(feedback)) { |
| 74 | + ctx.feedback[key] = ctx.feedback[key].concat(feedback) |
| 75 | + } else { |
| 76 | + ctx.feedback[key].push(feedback) |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + async validate (input, state) { |
| 82 | + const ctx = { |
| 83 | + options: this.options, |
| 84 | + validations: {}, |
| 85 | + feedback: {}, |
| 86 | + data: {}, |
| 87 | + input, |
| 88 | + state, |
| 89 | + failureCount: 0, |
| 90 | + get isValid () { |
| 91 | + return !this.failureCount |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + for (const [key, field] of Object.entries(this.fields)) { |
| 96 | + const { canBeEmpty } = field |
| 97 | + |
| 98 | + // Add the input to the data map so that the subset of data can be used |
| 99 | + // later. |
| 100 | + if (has(input, key)) ctx.data[key] = pipe(...field.modifiers)(input[key]) |
| 101 | + |
| 102 | + const vInput = ctx.data[key] |
| 103 | + const vState = state && state[key] |
| 104 | + if (canBeEmpty) { |
| 105 | + // If the field can be empty, skip other validations if the canBeEmpty |
| 106 | + // validation is valid. |
| 107 | + ctx.validations[key] = await canBeEmpty.validate(vInput, vState, ctx) |
| 108 | + if (ctx.validations[key].isValid) continue |
| 109 | + } |
| 110 | + |
| 111 | + if (!canBeEmpty && isEmpty(vInput)) { |
| 112 | + // If the field can't be empty and is empty, mark it as invalid and skip |
| 113 | + // validations. |
| 114 | + ctx.validations[key] = { isValid: false, isEmpty: true } |
| 115 | + } else { |
| 116 | + // Perform the validation(s). |
| 117 | + for (const validator of field.validators) { |
| 118 | + try { |
| 119 | + ctx.validations[key] = await validator.validate(vInput, vState, ctx) |
| 120 | + if (field.isSchemaValidator) { |
| 121 | + ctx.data[key] = ctx.validations[key].data |
| 122 | + } |
| 123 | + } catch (err) { |
| 124 | + ctx.validations[key] = { isValid: false, err } |
| 125 | + } |
| 126 | + if (!ctx.validations[key].isValid) break |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Perform validation failure steps if the validation fails. |
| 131 | + if (ctx.validations[key] && ctx.validations[key].isValid === false) { |
| 132 | + ctx.failureCount++ |
| 133 | + this.handleFailure(ctx, key, field) |
| 134 | + if (ctx.options.failFast && ctx.options.failFast === ctx.failureCount) { |
| 135 | + break |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + return ctx |
| 141 | + } |
| 142 | +} |
0 commit comments