import type {Plugin, CodeKeywordDefinition, KeywordCxt, ErrorObject, Code} from "ajv" import Ajv, {_, str, stringify, Name} from "ajv" import {and, or, not, strConcat} from "ajv/dist/compile/codegen" import {safeStringify, _Code} from "ajv/dist/compile/codegen/code" import {getData} from "ajv/dist/compile/context" import {reportError} from "ajv/dist/compile/errors" import N from "ajv/dist/compile/names" type ErrorsMap = {[P in T]?: ErrorObject[]} type StringMap = {[P in string]?: string} type ErrorMessageSchema = { properties?: StringMap items?: string[] required?: string | StringMap dependencies?: string | StringMap _?: string } & {[K in string]?: string | StringMap} interface ChildErrors { props?: ErrorsMap items?: ErrorsMap } const keyword = "errorMessage" const used: Name = new Name("emUsed") const KEYWORD_PROPERTY_PARAMS = { required: "missingProperty", dependencies: "property", dependentRequired: "property", } export interface ErrorMessageOptions { keepErrors?: boolean singleError?: boolean | string } const INTERPOLATION = /\$\{[^}]+\}/ const INTERPOLATION_REPLACE = /\$\{([^}]+)\}/g const EMPTY_STR = /^""\s*\+\s*|\s*\+\s*""$/g function errorMessage(options: ErrorMessageOptions): CodeKeywordDefinition { return { keyword, schemaType: ["string", "object"], post: true, code(cxt: KeywordCxt) { const {gen, data, schema, schemaValue, it} = cxt if (it.createErrors === false) return const sch: ErrorMessageSchema | string = schema const dataPath = strConcat(N.dataPath, it.errorPath) gen.if(_`${N.errors} > 0`, () => { if (typeof sch == "object") { const [kwdPropErrors, kwdErrors] = keywordErrorsConfig(sch) if (kwdErrors) processKeywordErrors(kwdErrors) if (kwdPropErrors) processKeywordPropErrors(kwdPropErrors) processChildErrors(childErrorsConfig(sch)) } const schMessage = typeof sch == "string" ? sch : sch._ if (schMessage) processAllErrors(schMessage) if (!options.keepErrors) removeUsedErrors() }) function childErrorsConfig({properties, items}: ErrorMessageSchema): ChildErrors { const errors: ChildErrors = {} if (properties) { errors.props = {} for (const p in properties) errors.props[p] = [] } if (items) { errors.items = {} for (let i = 0; i < items.length; i++) errors.items[i] = [] } return errors } function keywordErrorsConfig( emSchema: ErrorMessageSchema ): [{[K in string]?: ErrorsMap} | undefined, ErrorsMap | undefined] { let propErrors: {[K in string]?: ErrorsMap} | undefined let errors: ErrorsMap | undefined for (const k in emSchema) { if (k === "properties" || k === "items") continue const kwdSch = emSchema[k] if (typeof kwdSch == "object") { propErrors ||= {} const errMap: ErrorsMap = (propErrors[k] = {}) for (const p in kwdSch) errMap[p] = [] } else { errors ||= {} errors[k] = [] } } return [propErrors, errors] } function processKeywordErrors(kwdErrors: ErrorsMap): void { const kwdErrs = gen.const("emErrors", stringify(kwdErrors)) const templates = gen.const("templates", getTemplatesCode(kwdErrors, schema)) gen.forOf("err", N.vErrors, (err) => gen.if(matchKeywordError(err, kwdErrs), () => gen.code(_`${kwdErrs}[${err}.keyword].push(${err})`).assign(_`${err}.${used}`, true) ) ) const {singleError} = options if (singleError) { const message = gen.let("message", _`""`) const paramsErrors = gen.let("paramsErrors", _`[]`) loopErrors((key) => { gen.if(message, () => gen.code(_`${message} += ${typeof singleError == "string" ? singleError : ";"}`) ) gen.code(_`${message} += ${errMessage(key)}`) gen.assign(paramsErrors, _`${paramsErrors}.concat(${kwdErrs}[${key}])`) }) reportError(cxt, {message, params: _`{errors: ${paramsErrors}}`}) } else { loopErrors((key) => reportError(cxt, { message: errMessage(key), params: _`{errors: ${kwdErrs}[${key}]}`, }) ) } function loopErrors(body: (key: Name) => void): void { gen.forIn("key", kwdErrs, (key) => gen.if(_`${kwdErrs}[${key}].length`, () => body(key))) } function errMessage(key: Name): Code { return _`${key} in ${templates} ? ${templates}[${key}]() : ${schemaValue}[${key}]` } } function processKeywordPropErrors(kwdPropErrors: {[K in string]?: ErrorsMap}): void { const kwdErrs = gen.const("emErrors", stringify(kwdPropErrors)) const templatesCode: [string, Code][] = [] for (const k in kwdPropErrors) { templatesCode.push([ k, getTemplatesCode(kwdPropErrors[k] as ErrorsMap, schema[k]), ]) } const templates = gen.const("templates", gen.object(...templatesCode)) const kwdPropParams = gen.scopeValue("obj", { ref: KEYWORD_PROPERTY_PARAMS, code: stringify(KEYWORD_PROPERTY_PARAMS), }) const propParam = gen.let("emPropParams") const paramsErrors = gen.let("emParamsErrors") gen.forOf("err", N.vErrors, (err) => gen.if(matchKeywordError(err, kwdErrs), () => { gen.assign(propParam, _`${kwdPropParams}[${err}.keyword]`) gen.assign(paramsErrors, _`${kwdErrs}[${err}.keyword][${err}.params[${propParam}]]`) gen.if(paramsErrors, () => gen.code(_`${paramsErrors}.push(${err})`).assign(_`${err}.${used}`, true) ) }) ) gen.forIn("key", kwdErrs, (key) => gen.forIn("keyProp", _`${kwdErrs}[${key}]`, (keyProp) => { gen.assign(paramsErrors, _`${kwdErrs}[${key}][${keyProp}]`) gen.if(_`${paramsErrors}.length`, () => { const tmpl = gen.const( "tmpl", _`${templates}[${key}] && ${templates}[${key}][${keyProp}]` ) reportError(cxt, { message: _`${tmpl} ? ${tmpl}() : ${schemaValue}[${key}][${keyProp}]`, params: _`{errors: ${paramsErrors}}`, }) }) }) ) } function processChildErrors(childErrors: ChildErrors): void { const {props, items} = childErrors if (!props && !items) return const isObj = _`typeof ${data} == "object"` const isArr = _`Array.isArray(${data})` const childErrs = gen.let("emErrors") let childKwd: Name let childProp: Code const templates = gen.let("templates") if (props && items) { childKwd = gen.let("emChildKwd") gen.if(isObj) gen.if( isArr, () => { init(items, schema.items) gen.assign(childKwd, str`items`) }, () => { init(props, schema.properties) gen.assign(childKwd, str`properties`) } ) childProp = _`[${childKwd}]` } else if (items) { gen.if(isArr) init(items, schema.items) childProp = _`.items` } else if (props) { gen.if(and(isObj, not(isArr))) init(props, schema.properties) childProp = _`.properties` } gen.forOf("err", N.vErrors, (err) => ifMatchesChildError(err, childErrs, (child) => gen.code(_`${childErrs}[${child}].push(${err})`).assign(_`${err}.${used}`, true) ) ) gen.forIn("key", childErrs, (key) => gen.if(_`${childErrs}[${key}].length`, () => { reportError(cxt, { message: _`${key} in ${templates} ? ${templates}[${key}]() : ${schemaValue}${childProp}[${key}]`, params: _`{errors: ${childErrs}[${key}]}`, }) gen.assign( _`${N.vErrors}[${N.errors}-1].dataPath`, _`${dataPath} + "/" + ${key}.replace(/~/g, "~0").replace(/\\//g, "~1")` ) }) ) gen.endIf() function init( children: ErrorsMap, msgs: {[K in string]?: string} ): void { gen.assign(childErrs, stringify(children)) gen.assign(templates, getTemplatesCode(children, msgs)) } } function processAllErrors(schMessage: string): void { const errs = gen.const("emErrs", _`[]`) gen.forOf("err", N.vErrors, (err) => gen.if(matchAnyError(err), () => gen.code(_`${errs}.push(${err})`).assign(_`${err}.${used}`, true) ) ) gen.if(_`${errs}.length`, () => reportError(cxt, { message: templateExpr(schMessage), params: _`{errors: ${errs}}`, }) ) } function removeUsedErrors(): void { const errs = gen.const("emErrs", _`[]`) gen.forOf("err", N.vErrors, (err) => gen.if(_`!${err}.${used}`, () => gen.code(_`${errs}.push(${err})`)) ) gen.assign(N.vErrors, errs).assign(N.errors, _`${errs}.length`) } function matchKeywordError(err: Name, kwdErrs: Name): Code { return and( _`${err}.keyword !== ${keyword}`, _`!${err}.${used}`, _`${err}.dataPath === ${dataPath}`, _`${err}.keyword in ${kwdErrs}`, // TODO match the end of the string? _`${err}.schemaPath.indexOf(${it.errSchemaPath}) === 0`, _`/^\\/[^\\/]*$/.test(${err}.schemaPath.slice(${it.errSchemaPath.length}))` ) } function ifMatchesChildError( err: Name, childErrs: Name, thenBody: (child: Name) => void ): void { gen.if( and( _`${err}.keyword !== ${keyword}`, _`!${err}.${used}`, _`${err}.dataPath.indexOf(${dataPath}) === 0` ), () => { const childRegex = gen.scopeValue("pattern", {ref: /^\/([^/]*)(?:\/|$)/}) const matches = gen.const( "emMatches", _`${childRegex}.exec(${err}.dataPath.slice(${dataPath}.length))` ) const child = gen.const( "emChild", _`${matches} && ${matches}[1].replace(/~1/g, "/").replace(/~0/g, "~")` ) gen.if(_`${child} !== undefined && ${child} in ${childErrs}`, () => thenBody(child)) } ) } function matchAnyError(err: Name): Code { return and( _`${err}.keyword !== ${keyword}`, _`!${err}.${used}`, or( _`${err}.dataPath === ${dataPath}`, and( _`${err}.dataPath.indexOf(${dataPath}) === 0`, _`${err}.dataPath[${dataPath}.length] === "/"` ) ), _`${err}.schemaPath.indexOf(${it.errSchemaPath}) === 0`, _`${err}.schemaPath[${it.errSchemaPath}.length] === "/"` ) } function getTemplatesCode(keys: Record, msgs: {[K in string]?: string}): Code { const templatesCode: [string, Code][] = [] for (const k in keys) { const msg = msgs[k] as string if (INTERPOLATION.test(msg)) templatesCode.push([k, templateFunc(msg)]) } return gen.object(...templatesCode) } function templateExpr(msg: string): Code { if (!INTERPOLATION.test(msg)) return stringify(msg) return new _Code( safeStringify(msg) .replace( INTERPOLATION_REPLACE, (_s, ptr) => `" + JSON.stringify(${getData(ptr, it)}) + "` ) .replace(EMPTY_STR, "") ) } function templateFunc(msg: string): Code { return _`function(){return ${templateExpr(msg)}}` } }, metaSchema: { anyOf: [ {type: "string"}, { type: "object", properties: { properties: {$ref: "#/$defs/stringMap"}, items: {$ref: "#/$defs/stringList"}, required: {$ref: "#/$defs/stringOrMap"}, dependencies: {$ref: "#/$defs/stringOrMap"}, }, additionalProperties: {type: "string"}, }, ], $defs: { stringMap: { type: "object", additionalProperties: {type: "string"}, }, stringOrMap: { anyOf: [{type: "string"}, {$ref: "#/$defs/stringMap"}], }, stringList: {type: "array", items: {type: "string"}}, }, }, } } const ajvErrors: Plugin = ( ajv: Ajv, options: ErrorMessageOptions = {} ): Ajv => { if (!ajv.opts.allErrors) throw new Error("ajv-errors: Ajv option allErrors must be true") if (ajv.opts.jsPropertySyntax) { throw new Error("ajv-errors: ajv option jsPropertySyntax is not supported") } return ajv.addKeyword(errorMessage(options)) } export default ajvErrors module.exports = ajvErrors module.exports.default = ajvErrors