mirror of https://github.com/jkjoy/sunpeiwen.git
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
|
import type {
|
||
|
AnySchema,
|
||
|
AnySchemaObject,
|
||
|
AnyValidateFunction,
|
||
|
AsyncValidateFunction,
|
||
|
EvaluatedProperties,
|
||
|
EvaluatedItems,
|
||
|
} from "../types"
|
||
|
import type Ajv from "../core"
|
||
|
import type {InstanceOptions} from "../core"
|
||
|
import {CodeGen, _, nil, stringify, Name, Code, ValueScopeName} from "./codegen"
|
||
|
import {ValidationError} from "./error_classes"
|
||
|
import N from "./names"
|
||
|
import {LocalRefs, getFullPath, _getFullPath, inlineRef, normalizeId, resolveUrl} from "./resolve"
|
||
|
import {schemaHasRulesButRef, unescapeFragment} from "./util"
|
||
|
import {validateFunctionCode} from "./validate"
|
||
|
import * as URI from "uri-js"
|
||
|
import {JSONType} from "./rules"
|
||
|
|
||
|
export type SchemaRefs = {
|
||
|
[Ref in string]?: SchemaEnv | AnySchema
|
||
|
}
|
||
|
|
||
|
export interface SchemaCxt {
|
||
|
readonly gen: CodeGen
|
||
|
readonly allErrors?: boolean // validation mode - whether to collect all errors or break on error
|
||
|
readonly data: Name // Name with reference to the current part of data instance
|
||
|
readonly parentData: Name // should be used in keywords modifying data
|
||
|
readonly parentDataProperty: Code | number // should be used in keywords modifying data
|
||
|
readonly dataNames: Name[]
|
||
|
readonly dataPathArr: (Code | number)[]
|
||
|
readonly dataLevel: number // the level of the currently validated data,
|
||
|
// it can be used to access both the property names and the data on all levels from the top.
|
||
|
dataTypes: JSONType[] // data types applied to the current part of data instance
|
||
|
definedProperties: Set<string> // set of properties to keep track of for required checks
|
||
|
readonly topSchemaRef: Code
|
||
|
readonly validateName: Name
|
||
|
evaluated?: Name
|
||
|
readonly ValidationError?: Name
|
||
|
readonly schema: AnySchema // current schema object - equal to parentSchema passed via KeywordCxt
|
||
|
readonly schemaEnv: SchemaEnv
|
||
|
readonly rootId: string
|
||
|
baseId: string // the current schema base URI that should be used as the base for resolving URIs in references (\$ref)
|
||
|
readonly schemaPath: Code // the run-time expression that evaluates to the property name of the current schema
|
||
|
readonly errSchemaPath: string // this is actual string, should not be changed to Code
|
||
|
readonly errorPath: Code
|
||
|
readonly propertyName?: Name
|
||
|
readonly compositeRule?: boolean // true indicates that the current schema is inside the compound keyword,
|
||
|
// where failing some rule doesn't mean validation failure (`anyOf`, `oneOf`, `not`, `if`).
|
||
|
// This flag is used to determine whether you can return validation result immediately after any error in case the option `allErrors` is not `true.
|
||
|
// You only need to use it if you have many steps in your keywords and potentially can define multiple errors.
|
||
|
props?: EvaluatedProperties | Name // properties evaluated by this schema - used by parent schema or assigned to validation function
|
||
|
items?: EvaluatedItems | Name // last item evaluated by this schema - used by parent schema or assigned to validation function
|
||
|
jtdDiscriminator?: string
|
||
|
jtdMetadata?: boolean
|
||
|
readonly createErrors?: boolean
|
||
|
readonly opts: InstanceOptions // Ajv instance option.
|
||
|
readonly self: Ajv // current Ajv instance
|
||
|
}
|
||
|
|
||
|
export interface SchemaObjCxt extends SchemaCxt {
|
||
|
readonly schema: AnySchemaObject
|
||
|
}
|
||
|
interface SchemaEnvArgs {
|
||
|
readonly schema: AnySchema
|
||
|
readonly root?: SchemaEnv
|
||
|
readonly baseId?: string
|
||
|
readonly localRefs?: LocalRefs
|
||
|
readonly meta?: boolean
|
||
|
}
|
||
|
|
||
|
export class SchemaEnv implements SchemaEnvArgs {
|
||
|
readonly schema: AnySchema
|
||
|
readonly root: SchemaEnv
|
||
|
baseId: string // TODO possibly, it should be readonly
|
||
|
localRefs?: LocalRefs
|
||
|
readonly meta?: boolean
|
||
|
readonly $async?: boolean // true if the current schema is asynchronous.
|
||
|
readonly refs: SchemaRefs = {}
|
||
|
readonly dynamicAnchors: {[Ref in string]?: true} = {}
|
||
|
validate?: AnyValidateFunction
|
||
|
validateName?: ValueScopeName
|
||
|
serialize?: (data: unknown) => string
|
||
|
serializeName?: ValueScopeName
|
||
|
parse?: (data: string) => unknown
|
||
|
parseName?: ValueScopeName
|
||
|
|
||
|
constructor(env: SchemaEnvArgs) {
|
||
|
let schema: AnySchemaObject | undefined
|
||
|
if (typeof env.schema == "object") schema = env.schema
|
||
|
this.schema = env.schema
|
||
|
this.root = env.root || this
|
||
|
this.baseId = env.baseId ?? normalizeId(schema?.$id)
|
||
|
this.localRefs = env.localRefs
|
||
|
this.meta = env.meta
|
||
|
this.$async = schema?.$async
|
||
|
this.refs = {}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// let codeSize = 0
|
||
|
// let nodeCount = 0
|
||
|
|
||
|
// Compiles schema in SchemaEnv
|
||
|
export function compileSchema(this: Ajv, sch: SchemaEnv): SchemaEnv {
|
||
|
// TODO refactor - remove compilations
|
||
|
const _sch = getCompilingSchema.call(this, sch)
|
||
|
if (_sch) return _sch
|
||
|
const rootId = getFullPath(sch.root.baseId) // TODO if getFullPath removed 1 tests fails
|
||
|
const {es5, lines} = this.opts.code
|
||
|
const {ownProperties} = this.opts
|
||
|
const gen = new CodeGen(this.scope, {es5, lines, ownProperties})
|
||
|
let _ValidationError
|
||
|
if (sch.$async) {
|
||
|
_ValidationError = gen.scopeValue("Error", {
|
||
|
ref: ValidationError,
|
||
|
code: _`require("ajv/dist/compile/error_classes").ValidationError`,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
const validateName = gen.scopeName("validate")
|
||
|
sch.validateName = validateName
|
||
|
|
||
|
const schemaCxt: SchemaCxt = {
|
||
|
gen,
|
||
|
allErrors: this.opts.allErrors,
|
||
|
data: N.data,
|
||
|
parentData: N.parentData,
|
||
|
parentDataProperty: N.parentDataProperty,
|
||
|
dataNames: [N.data],
|
||
|
dataPathArr: [nil], // TODO can its length be used as dataLevel if nil is removed?
|
||
|
dataLevel: 0,
|
||
|
dataTypes: [],
|
||
|
definedProperties: new Set<string>(),
|
||
|
topSchemaRef: gen.scopeValue(
|
||
|
"schema",
|
||
|
this.opts.code.source === true
|
||
|
? {ref: sch.schema, code: stringify(sch.schema)}
|
||
|
: {ref: sch.schema}
|
||
|
),
|
||
|
validateName,
|
||
|
ValidationError: _ValidationError,
|
||
|
schema: sch.schema,
|
||
|
schemaEnv: sch,
|
||
|
rootId,
|
||
|
baseId: sch.baseId || rootId,
|
||
|
schemaPath: nil,
|
||
|
errSchemaPath: this.opts.jtd ? "" : "#",
|
||
|
errorPath: _`""`,
|
||
|
opts: this.opts,
|
||
|
self: this,
|
||
|
}
|
||
|
|
||
|
let sourceCode: string | undefined
|
||
|
try {
|
||
|
this._compilations.add(sch)
|
||
|
validateFunctionCode(schemaCxt)
|
||
|
gen.optimize(this.opts.code.optimize)
|
||
|
// gen.optimize(1)
|
||
|
const validateCode = gen.toString()
|
||
|
sourceCode = `${gen.scopeRefs(N.scope)}return ${validateCode}`
|
||
|
// console.log((codeSize += sourceCode.length), (nodeCount += gen.nodeCount))
|
||
|
if (this.opts.code.process) sourceCode = this.opts.code.process(sourceCode, sch)
|
||
|
// console.log("\n\n\n *** \n", sourceCode)
|
||
|
const makeValidate = new Function(`${N.self}`, `${N.scope}`, sourceCode)
|
||
|
const validate: AnyValidateFunction = makeValidate(this, this.scope.get())
|
||
|
this.scope.value(validateName, {ref: validate})
|
||
|
|
||
|
validate.errors = null
|
||
|
validate.schema = sch.schema
|
||
|
validate.schemaEnv = sch
|
||
|
if (sch.$async) (validate as AsyncValidateFunction).$async = true
|
||
|
if (this.opts.code.source === true) {
|
||
|
validate.source = {validateName, validateCode, scopeValues: gen._values}
|
||
|
}
|
||
|
if (this.opts.unevaluated) {
|
||
|
const {props, items} = schemaCxt
|
||
|
validate.evaluated = {
|
||
|
props: props instanceof Name ? undefined : props,
|
||
|
items: items instanceof Name ? undefined : items,
|
||
|
dynamicProps: props instanceof Name,
|
||
|
dynamicItems: items instanceof Name,
|
||
|
}
|
||
|
if (validate.source) validate.source.evaluated = stringify(validate.evaluated)
|
||
|
}
|
||
|
sch.validate = validate
|
||
|
return sch
|
||
|
} catch (e) {
|
||
|
delete sch.validate
|
||
|
delete sch.validateName
|
||
|
if (sourceCode) this.logger.error("Error compiling schema, function code:", sourceCode)
|
||
|
// console.log("\n\n\n *** \n", sourceCode, this.opts)
|
||
|
throw e
|
||
|
} finally {
|
||
|
this._compilations.delete(sch)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function resolveRef(
|
||
|
this: Ajv,
|
||
|
root: SchemaEnv,
|
||
|
baseId: string,
|
||
|
ref: string
|
||
|
): AnySchema | SchemaEnv | undefined {
|
||
|
ref = resolveUrl(baseId, ref)
|
||
|
const schOrFunc = root.refs[ref]
|
||
|
if (schOrFunc) return schOrFunc
|
||
|
|
||
|
let _sch = resolve.call(this, root, ref)
|
||
|
if (_sch === undefined) {
|
||
|
const schema = root.localRefs?.[ref] // TODO maybe localRefs should hold SchemaEnv
|
||
|
if (schema) _sch = new SchemaEnv({schema, root, baseId})
|
||
|
}
|
||
|
|
||
|
if (_sch === undefined) return
|
||
|
return (root.refs[ref] = inlineOrCompile.call(this, _sch))
|
||
|
}
|
||
|
|
||
|
function inlineOrCompile(this: Ajv, sch: SchemaEnv): AnySchema | SchemaEnv {
|
||
|
if (inlineRef(sch.schema, this.opts.inlineRefs)) return sch.schema
|
||
|
return sch.validate ? sch : compileSchema.call(this, sch)
|
||
|
}
|
||
|
|
||
|
// Index of schema compilation in the currently compiled list
|
||
|
export function getCompilingSchema(this: Ajv, schEnv: SchemaEnv): SchemaEnv | void {
|
||
|
for (const sch of this._compilations) {
|
||
|
if (sameSchemaEnv(sch, schEnv)) return sch
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function sameSchemaEnv(s1: SchemaEnv, s2: SchemaEnv): boolean {
|
||
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId
|
||
|
}
|
||
|
|
||
|
// resolve and compile the references ($ref)
|
||
|
// TODO returns AnySchemaObject (if the schema can be inlined) or validation function
|
||
|
function resolve(
|
||
|
this: Ajv,
|
||
|
root: SchemaEnv, // information about the root schema for the current schema
|
||
|
ref: string // reference to resolve
|
||
|
): SchemaEnv | undefined {
|
||
|
let sch
|
||
|
while (typeof (sch = this.refs[ref]) == "string") ref = sch
|
||
|
return sch || this.schemas[ref] || resolveSchema.call(this, root, ref)
|
||
|
}
|
||
|
|
||
|
// Resolve schema, its root and baseId
|
||
|
export function resolveSchema(
|
||
|
this: Ajv,
|
||
|
root: SchemaEnv, // root object with properties schema, refs TODO below SchemaEnv is assigned to it
|
||
|
ref: string // reference to resolve
|
||
|
): SchemaEnv | undefined {
|
||
|
const p = URI.parse(ref)
|
||
|
const refPath = _getFullPath(p)
|
||
|
let baseId = getFullPath(root.baseId)
|
||
|
// TODO `Object.keys(root.schema).length > 0` should not be needed - but removing breaks 2 tests
|
||
|
if (Object.keys(root.schema).length > 0 && refPath === baseId) {
|
||
|
return getJsonPointer.call(this, p, root)
|
||
|
}
|
||
|
|
||
|
const id = normalizeId(refPath)
|
||
|
const schOrRef = this.refs[id] || this.schemas[id]
|
||
|
if (typeof schOrRef == "string") {
|
||
|
const sch = resolveSchema.call(this, root, schOrRef)
|
||
|
if (typeof sch?.schema !== "object") return
|
||
|
return getJsonPointer.call(this, p, sch)
|
||
|
}
|
||
|
|
||
|
if (typeof schOrRef?.schema !== "object") return
|
||
|
if (!schOrRef.validate) compileSchema.call(this, schOrRef)
|
||
|
if (id === normalizeId(ref)) {
|
||
|
const {schema} = schOrRef
|
||
|
if (schema.$id) baseId = resolveUrl(baseId, schema.$id)
|
||
|
return new SchemaEnv({schema, root, baseId})
|
||
|
}
|
||
|
return getJsonPointer.call(this, p, schOrRef)
|
||
|
}
|
||
|
|
||
|
const PREVENT_SCOPE_CHANGE = new Set([
|
||
|
"properties",
|
||
|
"patternProperties",
|
||
|
"enum",
|
||
|
"dependencies",
|
||
|
"definitions",
|
||
|
])
|
||
|
|
||
|
function getJsonPointer(
|
||
|
this: Ajv,
|
||
|
parsedRef: URI.URIComponents,
|
||
|
{baseId, schema, root}: SchemaEnv
|
||
|
): SchemaEnv | undefined {
|
||
|
if (parsedRef.fragment?.[0] !== "/") return
|
||
|
for (const part of parsedRef.fragment.slice(1).split("/")) {
|
||
|
if (typeof schema == "boolean") return
|
||
|
schema = schema[unescapeFragment(part)]
|
||
|
if (schema === undefined) return
|
||
|
// TODO PREVENT_SCOPE_CHANGE could be defined in keyword def?
|
||
|
if (!PREVENT_SCOPE_CHANGE.has(part) && typeof schema == "object" && schema.$id) {
|
||
|
baseId = resolveUrl(baseId, schema.$id)
|
||
|
}
|
||
|
}
|
||
|
let env: SchemaEnv | undefined
|
||
|
if (typeof schema != "boolean" && schema.$ref && !schemaHasRulesButRef(schema, this.RULES)) {
|
||
|
const $ref = resolveUrl(baseId, schema.$ref)
|
||
|
env = resolveSchema.call(this, root, $ref)
|
||
|
}
|
||
|
// even though resolution failed we need to return SchemaEnv to throw exception
|
||
|
// so that compileAsync loads missing schema.
|
||
|
env = env || new SchemaEnv({schema, root, baseId})
|
||
|
if (env.schema !== env.root.schema) return env
|
||
|
return undefined
|
||
|
}
|