json-schema-library
Version:
Customizable and hackable json-validator and json-schema utilities for traversal, data generation and validation
478 lines (434 loc) • 17.6 kB
text/typescript
import copy from "fast-copy";
import sanitizeErrors from "./utils/sanitizeErrors";
import settings from "./settings";
import type { JsonSchemaReducer, JsonSchemaResolver, JsonSchemaValidator, Keyword, ValidationPath } from "./Keyword";
import { createSchema } from "./methods/createSchema";
import { Draft } from "./Draft";
import { toSchemaNodes } from "./methods/toSchemaNodes";
import { isJsonError, JsonSchema, JsonError, ErrorData, DefaultErrors, OptionalNodeOrError } from "./types";
import { isObject } from "./utils/isObject";
import { join } from "@sagold/json-pointer";
import { joinId } from "./utils/joinId";
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";
const { DYNAMIC_PROPERTIES } = 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).test($schema)) ?? drafts[drafts.length - 1];
}
export type Context = {
/** root node of this JSON Schema */
rootNode: SchemaNode;
/** 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;
};
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[];
resolveRef?: (args?: { pointer?: string; path?: ValidationPath }) => SchemaNode;
// 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>;
else?: SchemaNode;
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;
not?: SchemaNode;
oneOf?: SchemaNode[];
patternProperties?: { name: string; pattern: RegExp; node: SchemaNode }[];
properties?: Record<string, SchemaNode>;
propertyNames?: SchemaNode;
then?: SchemaNode;
unevaluatedItems?: SchemaNode;
unevaluatedProperties?: SchemaNode;
}
type SchemaNodeMethodsType = typeof SchemaNodeMethods;
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 Promises resolving to `JsonError|undefined` or empty.
*/
errorsAsync: Promise<JsonError | undefined>[];
};
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 = this.evaluationPath,
schemaLocation?: string,
dynamicId?: string
): SchemaNode {
const nextFragment = evaluationPath.split("/$ref")[0];
const parentNode = this as SchemaNode;
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
};
addKeywords(node);
return node;
},
createError<T extends string = DefaultErrors>(code: T, data: ErrorData, message?: string): JsonError {
let errorMessage = message;
if (errorMessage === undefined) {
const error = this.schema?.errorMessages?.[code] ?? this.context.errors[code];
if (typeof error === "function") {
return error(data);
}
errorMessage = render(error ?? name, data);
}
return { type: "error", code, message: errorMessage, 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 }).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, pointer, 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 (let i = 0; i < reducers.length; i += 1) {
const result = reducers[i]({ data, key, node, pointer, 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);
}
}
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, ...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 errors = validateNode(this, data, pointer, path) ?? [];
const syncErrors: JsonError[] = [];
const flatErrorList = sanitizeErrors(Array.isArray(errors) ? errors : [errors]).filter(isJsonError);
const errorsAsync: Promise<JsonError | undefined>[] = [];
sanitizeErrors(Array.isArray(errors) ? errors : [errors]).forEach((error) => {
if (isJsonError(error)) {
syncErrors.push(error);
} else if (error instanceof Promise) {
errorsAsync.push(error);
}
});
const result: ValidateReturnType = {
valid: flatErrorList.length === 0,
errors: syncErrors,
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): SchemaNode {
// @draft >= 6
schema.$id = joinId(schema.$id || url);
const { context } = this as SchemaNode;
const draft = getDraft(context.drafts, schema?.$schema ?? this.context.rootNode.$schema);
const node: SchemaNode = {
evaluationPath: "#",
lastIdPointer: "#",
schemaLocation: "#",
dynamicId: "",
reducers: [],
resolvers: [],
validators: [],
schema,
context: {
...context,
refs: {},
anchors: {},
...copy(pick(draft, "methods", "keywords", "version", "formats", "errors"))
},
...SchemaNodeMethods
} as SchemaNode;
node.context.rootNode = node;
node.context.remotes[joinId(url)] = node;
addKeywords(node);
return this;
},
/**
* @returns a list of all sub-schema as SchemaNode
*/
toSchemaNodes() {
return toSchemaNodes(this as SchemaNode);
},
/**
* @returns a list of values (including objects and arrays) and their corresponding JSON Schema as SchemaNode
*/
toDataNodes(data: unknown, pointer?: string) {
const node = this as SchemaNode;
return node.context.methods.toDataNodes(node, data, pointer);
},
toJSON() {
return { ...this, context: undefined, errors: undefined, parent: this.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
node.context.keywords
.filter(({ keyword }) => whitelist.includes(keyword))
.forEach((keyword) => execKeyword(keyword, node));
return;
}
const keys = Object.keys(node.schema);
node.context.keywords
.filter(({ keyword }) => keys.includes(keyword) || whitelist.includes(keyword))
.forEach((keyword) => execKeyword(keyword, node));
}
export function execKeyword(keyword: Keyword, node: SchemaNode) {
// @todo consider first parsing all nodes
keyword.parse?.(node);
if (keyword.addReduce?.(node)) {
node.reducers.push(keyword.reduce);
}
if (keyword.addResolve?.(node)) {
node.resolvers.push(keyword.resolve);
}
if (keyword.addValidate?.(node)) {
node.validators.push(keyword.validate);
}
}