UNPKG

json-schema-library

Version:

Customizable and hackable json-validator and json-schema utilities for traversal, data generation and validation

659 lines (606 loc) 24.9 kB
import { copy } from "fast-copy"; import sanitizeErrors from "./utils/sanitizeErrors"; import settings from "./settings"; import type { JsonSchemaReducer, JsonSchemaResolver, JsonSchemaValidator, Keyword, Maybe, ValidationAnnotation, ValidationPath } from "./Keyword"; import { createSchema } from "./methods/createSchema"; import { Draft } from "./Draft"; import { toSchemaNodes } from "./methods/toSchemaNodes"; import { isJsonError, isJsonSchema, JsonSchema, BooleanSchema, JsonError, AnnotationData, DefaultErrors, OptionalNodeOrError, NodeOrError, JsonAnnotation, isJsonAnnotation, isBooleanSchema } from "./types"; import { isObject } from "./utils/isObject"; import { join } from "@sagold/json-pointer"; import { resolveUri } from "./utils/resolveUri"; import { mergeNode } from "./mergeNode"; import { omit } from "./utils/omit"; import { pick } from "./utils/pick"; import { render } from "./errors/render"; import { TemplateOptions } from "./methods/getData"; import { validateNode } from "./validateNode"; import { hasProperty } from "./utils/hasProperty"; import { getNode } from "./getNode"; import { getNodeChild } from "./getNodeChild"; import { DataNode } from "./methods/toDataNodes"; const { DYNAMIC_PROPERTIES, REGEX_FLAGS, DECLARATOR_ONEOF, VALID_ANNOTATION_KEYWORDS } = settings; export function isSchemaNode(value: unknown): value is SchemaNode { return isObject(value) && Array.isArray(value?.reducers) && Array.isArray(value?.resolvers); } export function isReduceable(node: SchemaNode) { for (let i = 0, l = DYNAMIC_PROPERTIES.length; i < l; i += 1) { // @ts-expect-error interface to object conversion if (hasProperty(node, DYNAMIC_PROPERTIES[i])) { return true; } } return false; } function getDraft(drafts: Draft[], $schema: string) { if (!Array.isArray(drafts) || drafts.length === 0) { throw new Error(`Missing drafts in 'compileSchema({ $schema: "${$schema}" })'`); } if (drafts.length === 1) { return drafts[0]; } return drafts.find((d) => new RegExp(d.$schemaRegEx, REGEX_FLAGS).test($schema)) ?? drafts[drafts.length - 1]; } export type Context = { /** root node of this JSON Schema */ rootNode: SchemaNode; /** Fallback _draft_ version in case no _draft_ is specified by `schema.$schema` */ draft?: string; /** available draft configurations */ drafts: Draft[]; /** [SHARED ACROSS REMOTES] root nodes of registered remote JSON Schema, stored by id/url */ remotes: Record<string, SchemaNode>; /** references stored by fully resolved schema-$id + local-pointer */ refs: Record<string, SchemaNode>; /** anchors stored by fully resolved schema-$id + $anchor */ anchors: Record<string, SchemaNode>; /** [SHARED ACROSS REMOTES] dynamicAnchors stored by fully resolved schema-$id + $anchor */ dynamicAnchors: Record<string, SchemaNode>; /** JSON Schema parser, validator, reducer and resolver for this JSON Schema (root schema and its child nodes) */ keywords: Draft["keywords"]; /** JSON Schema draft dependend methods */ methods: Draft["methods"]; /** draft version */ version: Draft["version"]; /** draft errors & template-strings */ errors: Draft["errors"]; /** draft formats & validators */ formats: Draft["formats"]; /** [SHARED USING ADD REMOTE] getData default options */ getDataDefaultOptions?: TemplateOptions; /** [SHARED USING ADD REMOTE] collect unknown keywords in schemaAnnotations */ withSchemaAnnotations?: boolean; /** [SHARED USING ADD REMOTE] throw error on validation when ref cannot be resolved */ throwOnInvalidRef?: boolean; }; export interface SchemaNode extends SchemaNodeMethodsType { /** shared context across nodes of JSON schema and shared properties across all remotes */ context: Context; /** JSON Schema of node */ schema: JsonSchema; /** * Evaluation Path - The location of the keyword that produced the annotation or error. * The purpose of this data is to show the resolution path which resulted in the subschema * that contains the keyword. * * - relative to the root of the principal schema; should include (inline) any $ref segments in the path * - JSON pointer */ evaluationPath: string; /** * Schema Location - The direct location to the keyword that produced the annotation * or error. This is provided as a convenience to the user so that they don't have to resolve * the keyword's subschema, which may not be trivial task. It is only provided if the relative * location contains $refs (otherwise, the two locations will be the same). * * - absolute URI * - may not have any association to the principal schema */ schemaLocation: string; /** id created when combining subschemas */ dynamicId: string; /** reference to parent node (node used to compile this node) */ parent?: SchemaNode | undefined; /** JSON Pointer from last $id ~~to this location~~ to resolve $refs to $id#/idLocalPointer */ lastIdPointer: string; /** when reduced schema containing `oneOf` schema, `oneOfIndex` stores `oneOf`-item used for merge */ oneOfIndex?: number; reducers: JsonSchemaReducer[]; resolvers: JsonSchemaResolver[]; validators: JsonSchemaValidator[]; schemaValidation?: ValidationAnnotation[]; // parsed schema properties (registered by parsers) $id?: string; $defs?: Record<string, SchemaNode>; $ref?: string; additionalProperties?: SchemaNode; allOf?: SchemaNode[]; anyOf?: SchemaNode[]; contains?: SchemaNode; dependentRequired?: Record<string, string[]>; dependentSchemas?: Record<string, SchemaNode | boolean>; deprecated?: boolean; else?: SchemaNode; enum?: unknown[]; if?: SchemaNode; /** * # Items-array schema - for all drafts * * - for drafts prior 2020-12 `schema.items[]`-schema stored as `node.prefixItems` * * Validation succeeds if each element of the instance validates against the schema at the * same position, if any. * * The `prefixItems` keyword restricts a number of items from the start of an array instance * to validate against the given sequence of subschemas, where the item at a given index in * the array instance is evaluated against the subschema at the given index in the `prefixItems` * array, if any. Array items outside the range described by the `prefixItems` keyword is * evaluated against the items keyword, if present. * * [Docs](https://www.learnjsonschema.com/2020-12/applicator/prefixitems/) * | [Examples](https://json-schema.org/understanding-json-schema/reference/array#tupleValidation) */ prefixItems?: SchemaNode[]; /** * # Items-object schema for additional array item - for all drafts * * - for drafts prior 2020-12 `schema.additionalItems` object-schema stored as `node.items` * * Validation succeeds if each element of the instance not covered by `prefixItems` validates * against this schema. * * The items keyword restricts array instance items not described by the sibling `prefixItems` * keyword (if any), to validate against the given subschema. Whetherthis keyword was evaluated * against any item of the array instance is reported using annotations. * * [Docs](https://www.learnjsonschema.com/2020-12/applicator/items/) * | [Examples](https://json-schema.org/understanding-json-schema/reference/array#items) * | [AdditionalItems Specification](https://json-schema.org/draft/2019-09/draft-handrews-json-schema-02#additionalItems) */ items?: SchemaNode; maximum?: number; minimum?: number; maxItems?: number; maxLength?: number; maxProperties?: number; minItems?: number; minLength?: number; minProperties?: number; not?: SchemaNode; oneOf?: SchemaNode[]; multipleOf?: number; pattern?: RegExp; patternProperties?: { name: string; pattern: RegExp; node: SchemaNode }[]; propertyDependencies?: Record<string, Record<string, SchemaNode>>; properties?: Record<string, SchemaNode>; propertyNames?: SchemaNode; required?: string[]; then?: SchemaNode; type?: string | string[]; unevaluatedItems?: SchemaNode; unevaluatedProperties?: SchemaNode; uniqueItems?: true; } /** * Fixed SchemaNode mixin methods */ interface SchemaNodeMethodsType { compileSchema( schema: JsonSchema | BooleanSchema, evaluationPath?: string, schemaLocation?: string, dynamicId?: string ): SchemaNode; createError<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonError; createAnnotation<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonAnnotation; createSchema(data?: unknown): JsonSchema; /** * Returns a node matching the given location (pointer) in data * * - the returned node will have a **reduced schema** based on given input data * - return returned node $ref is resolved * * To resolve dynamic schema where the type of JSON Schema is evaluated by * its value, a data object has to be passed in options. * * Per default this function will return `undefined` schema for valid properties * that do not have a defined schema. Use the option `withSchemaWarning: true` to * receive an error with `code: schema-warning` containing the location of its * last evaluated json-schema. * * @returns { node } or { error } where node can also be undefined (valid but undefined) */ getNode(pointer: string, data: unknown, options: { withSchemaWarning: true } & GetNodeOptions): NodeOrError; getNode(pointer: string, data: unknown, options: { createSchema: true } & GetNodeOptions): NodeOrError; getNode(pointer: string, data?: unknown, options?: GetNodeOptions): OptionalNodeOrError; /** * Returns the child for the given property-name or array-index * * - the returned child node is **not reduced** * - a child node $ref is resolved * * @returns { node } or { error } where node can also be undefined (valid but undefined) */ getNodeChild( key: string | number, data: unknown, options: { withSchemaWarning: true } & GetNodeOptions ): NodeOrError; getNodeChild(key: string | number, data: unknown, options: { createSchema: true } & GetNodeOptions): NodeOrError; getNodeChild(key: string | number, data?: unknown, options?: GetNodeOptions): OptionalNodeOrError; getChildSelection(property: string | number): JsonError | SchemaNode[]; getNodeRef($ref: string): SchemaNode | undefined; getNodeRoot(): SchemaNode; getDraftVersion(): string; getData(data?: unknown, options?: TemplateOptions): any; // eslint-disable-line @typescript-eslint/no-explicit-any reduceNode( data: unknown, options?: { key?: string | number; pointer?: string; path?: ValidationPath } ): OptionalNodeOrError; resolveRef: (args?: { pointer?: string; path?: ValidationPath }) => SchemaNode; validate(data: unknown, pointer?: string, path?: ValidationPath): ValidateReturnType; addRemoteSchema(url: string, schema: JsonSchema | BooleanSchema): SchemaNode; toSchemaNodes(): SchemaNode[]; toDataNodes(data: unknown, pointer?: string): DataNode[]; toJSON(): unknown; } export type GetNodeOptions = { /** * Per default `undefined` is returned for valid data, but undefined schema. * * - Using `withSchemaWarning:true` will return an error instead: `{ type: "error", code: "schema-warning" }` */ withSchemaWarning?: boolean; /** * Per default `undefined` is returned for valid data, but undefined schema. * * - Using `createSchema:true` will create a schema instead */ createSchema?: boolean; path?: ValidationPath; pointer?: string; }; export type ValidateReturnType = { /** * True, if data is valid to the compiled schema. * Does not include async errors. */ valid: boolean; /** * List of validation errors or empty */ errors: JsonError[]; /** * List of annotations from validators */ annotations: JsonAnnotation[]; /** * List of Promises resolving to `JsonError|undefined` or empty. */ errorsAsync: Promise<Maybe<ValidationAnnotation>[]>[]; }; export function joinDynamicId(a?: string, b?: string) { if (a == b) { return a ?? ""; } if (a == null || b == null) { return (a || b) ?? ""; } if (a.startsWith(b)) { return a; } if (b.startsWith(a)) { return b; } return `${a}+${b}`; } export const SchemaNodeMethods = { /** * Compiles a child-schema of this node to its context * @returns SchemaNode representing the passed JSON Schema */ compileSchema(schema: JsonSchema, evaluationPath: string, schemaLocation?: string, dynamicId?: string): SchemaNode { const parentNode = this as SchemaNode; evaluationPath = evaluationPath ?? parentNode.evaluationPath; const nextFragment = evaluationPath.split("/$ref")[0]; const node: SchemaNode = { lastIdPointer: parentNode.lastIdPointer, // ref helper context: parentNode.context, parent: parentNode, evaluationPath, dynamicId: joinDynamicId(parentNode.dynamicId, dynamicId), schemaLocation: schemaLocation ?? join(parentNode.schemaLocation, nextFragment), reducers: [], resolvers: [], validators: [], schema, ...SchemaNodeMethods }; if (!isJsonSchema(schema) && !isBooleanSchema(schema)) { node.schemaValidation = [ node.createError("schema-error", { pointer: schemaLocation ?? evaluationPath, schema, value: undefined, message: `JSON schema must be object or boolean - reveived: '${schema}'` }) ]; return node; } const schemaValidation = addKeywords(node).filter((err) => err != null); node.schemaValidation = sanitizeErrors(schemaValidation); return node; }, createError<T extends string = DefaultErrors>(code: T, data: AnnotationData, message?: string): JsonError { const node = this as SchemaNode; let errorMessage = message; if (errorMessage === undefined) { const error = node.schema?.errorMessages?.[code] ?? node.context.errors[code]; if (typeof error === "function") { return error(data); } errorMessage = render(error ?? name, data); } return { type: "error", code, message: errorMessage, data }; }, createAnnotation<T extends string = DefaultErrors>( code: T, data: AnnotationData, message?: string ): JsonAnnotation { const node = this as SchemaNode; let annotationMessage = message; if (annotationMessage === undefined) { const error = node.schema?.errorMessages?.[code] ?? node.context.errors[code]; if (typeof error === "function") { return error(data); } annotationMessage = render(error ?? name, data); } return { type: "annotation", code, message: annotationMessage, data }; }, createSchema, getChildSelection(property: string | number): JsonError | SchemaNode[] { const node = this as SchemaNode; return node.context.methods.getChildSelection(node, property); }, getNode, getNodeChild, /** * @returns for $ref, the corresponding SchemaNode or undefined */ getNodeRef($ref: string): SchemaNode | undefined { const node = this as SchemaNode; return node.compileSchema({ $ref }, "$dynamic").resolveRef(); }, getNodeRoot() { const node = this as SchemaNode; return node.context.rootNode; }, /** * @returns draft version this JSON Schema is evaluated by */ getDraftVersion() { return (this as SchemaNode).context.version; }, /** * @returns data that is valid to the schema of this node */ getData(data?: unknown, options?: TemplateOptions) { const node = this as SchemaNode; const opts = { recursionLimit: 1, ...node.context.getDataDefaultOptions, cache: {}, ...(options ?? {}) }; return node.context.methods.getData(node, data, opts); }, /** * @returns SchemaNode with a reduced JSON Schema matching the given data */ reduceNode( data: unknown, options: { key?: string | number; pointer?: string; path?: ValidationPath } = {} ): OptionalNodeOrError { const node = this as SchemaNode; const { key = "missing-key", pointer = node.evaluationPath, path } = options; // @ts-expect-error bool schema if (node.schema === false) { return { node, error: undefined }; // @ts-expect-error bool schema } else if (node.schema === true) { const nextNode = node.compileSchema(createSchema(data), node.evaluationPath, node.schemaLocation); path?.push({ pointer, node }); return { node: nextNode, error: undefined }; } let schema; // we need to copy node to prevent modification of source // @todo does mergeNode break immutability? let workingNode = node.compileSchema(node.schema, node.evaluationPath, node.schemaLocation); const reducers = node.reducers; for (const reducer of reducers) { const result = reducer({ data, key, node, pointer, path: path ?? [] }); if (isJsonError(result)) { return { node: undefined, error: result }; } if (result) { // @ts-expect-error bool schema - for undefined & false schema return false schema if (result.schema === false) { schema = false; break; } // compilation result for data of current schemain order to merge results, we rebuild // node from schema alternatively we would need to merge by node-property workingNode = mergeNode(workingNode, result) as SchemaNode; } } if (schema === false) { // @ts-expect-error bool schema return { node: { ...node, schema: false, reducers: [] } as SchemaNode, error: undefined }; } if (workingNode !== node) { path?.push({ pointer, node }); } // remove dynamic properties of node workingNode.schema = omit(workingNode.schema, DECLARATOR_ONEOF, ...DYNAMIC_PROPERTIES); // @ts-expect-error string accessing schema props DYNAMIC_PROPERTIES.forEach((prop) => (workingNode[prop] = undefined)); return { node: workingNode, error: undefined }; }, /** * @returns validation result of data validated by this node's JSON Schema */ validate(data: unknown, pointer = "#", path: ValidationPath = []) { const node = this as SchemaNode; const errors = validateNode(node, data, pointer, path) ?? []; const syncErrors: JsonError[] = []; const annotations: JsonAnnotation[] = []; const flatErrorList = sanitizeErrors(Array.isArray(errors) ? errors : [errors]).filter(isJsonError); const errorsAsync: Promise<Maybe<ValidationAnnotation>[]>[] = []; sanitizeErrors(Array.isArray(errors) ? errors : [errors]).forEach((error) => { if (isJsonError(error)) { if (node.context.throwOnInvalidRef && error.code === "ref-error") { const refError = new Error("Invalid $ref: " + error.message); // @ts-expect-error unknown error-property refError.data = syncErrors; throw refError; } syncErrors.push(error); } else if (error instanceof Promise) { errorsAsync.push(error.then(sanitizeErrors)); } else if (isJsonAnnotation(error)) { annotations.push(error); } }); const result: ValidateReturnType = { valid: flatErrorList.length === 0, errors: syncErrors, annotations, errorsAsync }; return result; }, /** * Register a JSON Schema as a remote-schema to be resolved by $ref, $anchor, etc * @returns the current node (not the remote schema-node) */ addRemoteSchema(url: string, schema: JsonSchema | BooleanSchema): SchemaNode { // @draft >= 6 if (isJsonSchema(schema)) { schema.$id = resolveUri(schema.$id || url); } const node = this as SchemaNode; const { context } = node; const schemaId = isJsonSchema(schema) ? (node.context.draft ?? schema.$schema) : undefined; const draft = getDraft(context.drafts, schemaId ?? context.rootNode.schema?.$schema); const remoteNode: SchemaNode = { evaluationPath: "#", lastIdPointer: "#", schemaLocation: "#", dynamicId: "", reducers: [], resolvers: [], validators: [], schema, context: { ...context, refs: {}, anchors: {}, ...copy(pick(draft, "methods", "keywords", "version", "formats", "errors")) }, ...SchemaNodeMethods } as SchemaNode; remoteNode.context.rootNode = remoteNode; remoteNode.context.remotes[resolveUri(url)] = remoteNode; addKeywords(remoteNode); return node; }, // eslint-disable-next-line @typescript-eslint/no-unused-vars resolveRef(args?: { pointer?: string; path?: ValidationPath }) { throw new Error("method 'resolveRef' is not implemented"); return this as SchemaNode; }, /** * @returns a list of all sub-schema as SchemaNode */ toSchemaNodes() { return toSchemaNodes(this); }, /** * @returns a list of values (including objects and arrays) and their corresponding JSON Schema as SchemaNode */ toDataNodes(data: unknown, pointer?: string): DataNode[] { const node = this as SchemaNode; return node.context.methods.toDataNodes(node, data, pointer); }, toJSON() { const node = this as SchemaNode; return { ...node, context: undefined, errors: undefined, parent: node.parent?.evaluationPath }; } } as const; const whitelist = ["$ref", "if", "$defs"]; const noRefMergeDrafts = ["draft-04", "draft-06", "draft-07"]; export function addKeywords(node: SchemaNode) { if (node.schema.$ref && noRefMergeDrafts.includes(node.context.version)) { // for these draft versions only ref is validated return node.context.keywords .filter(({ keyword }) => whitelist.includes(keyword)) .map((keyword) => execKeyword(keyword, node)); } const keys = Object.keys(node.schema); const errors = node.context.keywords .filter(({ keyword }) => whitelist.includes(keyword) || keys.includes(keyword)) .map((keyword) => execKeyword(keyword, node)); keys.filter( (key) => !key.startsWith("x-") && !VALID_ANNOTATION_KEYWORDS.includes(key) && node.context.keywords.find((keyword) => keyword.keyword === key) == null ).forEach((keyword) => { errors.push( node.createAnnotation("unknown-keyword-warning", { pointer: `${node.schemaLocation}/${keyword}`, schema: node.schema, value: keyword, draft: node.getDraftVersion() }) ); }); return errors; } export function execKeyword(keyword: Keyword, node: SchemaNode) { // @todo consider first parsing all nodes const errors = keyword.parse?.(node); if (keyword.reduce && keyword.addReduce?.(node)) { node.reducers.push(keyword.reduce); } if (keyword.resolve && keyword.addResolve?.(node)) { node.resolvers.push(keyword.resolve); } if (keyword.validate && keyword.addValidate?.(node)) { node.validators.push(keyword.validate); } return errors; }