docschema
Version:
Schema declaration and validation library using JsDoc comments
462 lines (396 loc) • 11.9 kB
JavaScript
import {
checkResult,
resetCheckResult,
startCheckResult,
throwLastError
} from './checkResult.js'
import { syntaxErrorAboutTags } from './errors/errors.js'
import { ValidationError } from './errors/ValidationError.js'
import { enquoteString, objectStringify } from './functions/utils.js'
import { check } from './parse-check-types/check.js'
export class DocSchemaValidator {
/**
* @returns {CheckResult}
*/
get lastError() {
return checkResult
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
*/
check(ast, value, forceStrict = false) {
return this.#validateOrCheckHelper(ast, value, false, forceStrict)
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
*/
validate(ast, value, forceStrict = false) {
return this.#validateOrCheckHelper(ast, value, true, forceStrict)
}
/**
* @param {Ast} ast
* @param {any[]} args
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
*/
checkFunctionArguments(
ast, args, forceStrict = false
) {
return this.#validateOrCheckFunctionArgumentsHelper(
ast, args, false, forceStrict
)
}
/**
* @param {Ast} ast
* @param {any[]} args
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError}
* Throws ValidationError if the validation is not successful
*/
validateFunctionArguments(
ast, args, forceStrict = false
) {
return this.#validateOrCheckFunctionArgumentsHelper(
ast, args, true, forceStrict
)
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
*/
checkReturns(ast, value, forceStrict = false) {
return this.#validateOrCheckTag('returns', ast, value, false, forceStrict)
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError | Error}
* Throws ValidationError if the validation is not successful.
* Throws Error if the input AST is not valid.
*/
validateReturns(ast, value, forceStrict = false) {
return this.#validateOrCheckTag('returns', ast, value, true, forceStrict)
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} throwOnError
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError | SyntaxError | Error}
*/
#validateOrCheckHelper(ast, value, throwOnError, forceStrict) {
const { elements } = ast
const useStrict = forceStrict || ast.strict
if (elements.param.length > 0) {
return this.#validateOrCheckParams(
'param', ast, value, throwOnError, useStrict
)
}
if (elements.typedef) {
return this.#validateOrCheckTypedef(
ast, value, throwOnError, useStrict
)
}
if (elements.enum) {
return this.#validateOrCheckTag(
'enum', ast, value, throwOnError, useStrict
)
}
if (elements.type) {
return this.#validateOrCheckTag(
'type', ast, value, throwOnError, useStrict
)
}
throw syntaxErrorAboutTags(ast.file, ast.endLine + 1, 0)
}
/**
* @param {Ast} ast
* @param {any[]} args
* @param {boolean} [throwOnError]
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError}
* Throws ValidationError if the validation is not successful
*/
#validateOrCheckFunctionArgumentsHelper(
ast, args, throwOnError = true, forceStrict = false
) {
/*
* Maybe @type pointing to a @callback is used,
* like this: @type {MyCallback}
*/
if (ast.elements.type && ast.elements.param.length === 0) {
return this.#validateOrCheckTag(
'type', ast, args, throwOnError, forceStrict
)
}
startCheckResult()
const parsedTags = ast.elements.param
if (ast.strict || forceStrict) {
const result = this.#validateStrictArguments(
ast, parsedTags, args, throwOnError
)
if (!result) {
checkResult.pass = false
return checkResult
}
}
for (const parsedTag of parsedTags) {
resetCheckResult()
const { id } = parsedTag
const arg = (parsedTag.destructured)
? args[id]?.[parsedTag.destructured[1]]
: args[id]
// undefined value on optional parameter is allowed
if (parsedTag.optional && arg === undefined) {
continue
}
const result = this.#validateOrCheck(
'argument', parsedTag, arg, id, ast, throwOnError, forceStrict
)
if (!throwOnError && !result) break
}
return checkResult
}
/**
* @param {'param' | 'property'} name
* @param {Ast} ast
* @param {any} value
* @param {boolean} [throwOnError]
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError | Error}
* Throws ValidationError if the validation is not successful.
* Throws Error if the input AST is not valid.
*/
#validateOrCheckParams(
name, ast, value, throwOnError = true, forceStrict = false
) {
startCheckResult()
const parsedTags = ast.elements[name]
if (ast.strict || forceStrict) {
const result = this.#validateStrictProperties(
name, ast, parsedTags, value, throwOnError
)
if (!result) {
checkResult.pass = false
return checkResult
}
}
for (const parsedTag of parsedTags) {
resetCheckResult()
const key = parsedTag.name
const val = (parsedTag.destructured)
? value[parsedTag.destructured[0]]?.[parsedTag.destructured[1]]
: value[parsedTag.name]
// undefined value on optional parameter is allowed
if (parsedTag.optional && val === undefined) {
continue
}
const result = this.#validateOrCheck(
name, parsedTag, val, key, ast, throwOnError, forceStrict
)
if (!throwOnError && !result) break
}
return checkResult
}
/**
* @param {'enum' | 'type' | 'returns' | 'yields'} tagName
* @param {Ast} ast
* @param {any} value
* @param {boolean} [throwOnError]
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError | Error}
* Throws ValidationError if the validation is not successful.
* Throws Error if the input AST is not valid.
*/
#validateOrCheckTag(
tagName, ast, value, throwOnError = true, forceStrict = false
) {
startCheckResult()
const parsedTag = ast.elements[tagName]
if (parsedTag === undefined) {
throw new Error('The AST you are trying to use is not valid')
}
if (parsedTag === null) {
checkResult.pass = (value === undefined)
return checkResult
}
this.#validateOrCheck(
tagName, parsedTag, value, '', ast, throwOnError, forceStrict
)
return checkResult
}
/**
* @param {Ast} ast
* @param {any} value
* @param {boolean} [throwOnError]
* @param {boolean} [forceStrict] Force strict recursively
* @returns {CheckResult}
* @throws {ValidationError | Error}
* Throws ValidationError if the validation is not successful.
* Throws Error if the input AST is not valid.
*/
#validateOrCheckTypedef(
ast, value, throwOnError = true, forceStrict = false
) {
startCheckResult()
const parsedTags = ast.elements?.typedef
if (parsedTags === undefined) {
throw new Error('The AST you are trying to use is not valid')
}
return this.#validateOrCheckParams(
'property', ast, value, throwOnError, forceStrict
)
}
/**
* @param {'argument' | string} tagName
* @param {ParsedTag} parsedTag
* @param {any} value
* @param {PropertyKey} key
* @param {Ast} ast
* @param {boolean} throwOnError
* @param {boolean} forceStrict
* @returns {boolean} true on success, false on failure
* @throws {ValidationError}
*/
#validateOrCheck(
tagName,
parsedTag,
value,
key,
ast,
throwOnError,
forceStrict
) {
const result = check(
parsedTag.types,
value,
key,
ast,
parsedTag.filters,
this,
throwOnError,
forceStrict
)
checkResult.tag = (tagName === 'argument') ? 'param' : tagName
if (!result) {
if (!checkResult.message) {
const expectedTypeExpression = parsedTag.types[0]?.typeExpression ?? ''
checkResult.message = this.#errorMessage(
tagName, expectedTypeExpression, value, key
)
}
if (throwOnError) throwLastError()
}
return result
}
/**
* @param {'argument' | string} tagName
* @param {Ast} ast
* @param {ParsedTag[]} parsedTags
* @param {any} value
* @param {boolean} throwOnError
* @returns {boolean} true on success, false on failure
* @throws {ValidationError}
*/
#validateStrictProperties(tagName, ast, parsedTags, value, throwOnError) {
const valueKeys = Object.keys(value)
const parsedTagKeys = new Array(parsedTags.length)
for (let i = 0; i < parsedTags.length; i++) {
parsedTagKeys[i] = parsedTags[i]?.name ?? ''
}
for (const key of valueKeys) {
if (!parsedTagKeys.includes(key)) {
checkResult.kind = 'strict'
checkResult.tag = (tagName === 'argument') ? 'param' : tagName
checkResult.message = `Strict validation failed. Property "${key}" is missing in the schema at file://${ast.file}:${ast.startLine}`
checkResult.pass = false
if (throwOnError) throwLastError()
return false
}
}
return true
}
/**
* @param {Ast} ast
* @param {ParsedTag[]} parsedTags
* @param {any} args
* @param {boolean} throwOnError
* @returns {boolean} true on success, false on failure
* @throws {ValidationError}
*/
#validateStrictArguments(ast, parsedTags, args, throwOnError) {
const result = (args.length <= parsedTags.length)
if (!result) {
checkResult.kind = 'strict'
checkResult.tag = 'param'
checkResult.message = `Arguments count is higher than expected in the schema at file://${ast.file}:${ast.startLine}`
checkResult.pass = false
if (throwOnError) throwLastError()
return false
}
return true
}
/**
* @param {string} tagName
* @param {string} type
* @param {any} value
* @param {PropertyKey} key
* @returns {string}
*/
#errorMessage(tagName, type, value, key) {
let what = ''
if (tagName === 'argument') {
what = tagName // 'argument'
}
else if (tagName === 'param' || tagName === 'property') {
what = enquoteString(key)
}
else if (tagName === 'returns') {
what = 'the returned value'
}
else {
what = 'value'
}
let message = `Expected ${what} to be of type ${type}, but the actual `
if (value === undefined || value === null) {
message += `value is ${value}`
}
else {
let constructorName = value?.constructor?.name
if (
constructorName === 'Boolean'
|| constructorName === 'Number'
|| constructorName === 'String'
|| constructorName === 'Array'
|| constructorName === 'Object'
) {
constructorName = ''
}
if (constructorName) {
message += `value is an instance of ${constructorName}`
}
else {
message += `type is ${typeof value}`
message += (value instanceof Object)
? `, and the value is ${objectStringify(value, 300)}`
: ''
}
}
return message
}
}