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 "../runtime/validation_error" 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 // 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 schemaId?: "$id" | "id" readonly root?: SchemaEnv readonly baseId?: string readonly schemaPath?: string readonly localRefs?: LocalRefs readonly meta?: boolean } export class SchemaEnv implements SchemaEnvArgs { readonly schema: AnySchema readonly schemaId?: "$id" | "id" readonly root: SchemaEnv baseId: string // TODO possibly, it should be readonly schemaPath?: string 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.schemaId = env.schemaId this.root = env.root || this this.baseId = env.baseId ?? normalizeId(schema?.[env.schemaId || "$id"]) this.schemaPath = env.schemaPath 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/runtime/validation_error").default`, }) } 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(), 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: sch.schemaPath || (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 const {schemaId} = this.opts if (schema) _sch = new SchemaEnv({schema, schemaId, 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 const {schemaId} = this.opts const schId = schema[schemaId] if (schId) baseId = resolveUrl(baseId, schId) return new SchemaEnv({schema, schemaId, 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? const schId = typeof schema == "object" && schema[this.opts.schemaId] if (!PREVENT_SCOPE_CHANGE.has(part) && schId) { baseId = resolveUrl(baseId, schId) } } 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. const {schemaId} = this.opts env = env || new SchemaEnv({schema, schemaId, root, baseId}) if (env.schema !== env.root.schema) return env return undefined }