json-schema-library
Version:
Customizable and hackable json-validator and json-schema utilities for traversal, data generation and validation
301 lines (269 loc) • 11.2 kB
text/typescript
import { isJsonError, isSchemaNode, JsonError, SchemaNode } from "../types";
import { Keyword, JsonSchemaValidatorParams, ValidationPath, JsonSchemaReducerParams } from "../Keyword";
import { resolveUri } from "../utils/resolveUri";
import splitRef from "../utils/splitRef";
import { omit } from "../utils/omit";
import { isObject } from "../utils/isObject";
import { validateNode } from "../validateNode";
import { get, split } from "@sagold/json-pointer";
import { mergeNode } from "../mergeNode";
import { pick } from "../utils/pick";
import settings from "src/settings";
export const $refKeyword: Keyword = {
id: "$ref",
keyword: "$ref",
order: 10,
parse: parseRef,
addReduce: (node) => node.$ref != null || node.schema.$dynamicRef != null,
reduce: reduceRef,
addValidate: ({ schema }) => schema.$ref != null || schema.$dynamicRef != null,
validate: validateRef
};
function register(node: SchemaNode, path: string) {
if (node.context.refs[path] == null) {
node.context.refs[path] = node;
}
}
export function parseRef(node: SchemaNode) {
// @ts-expect-error add ref resolution method to node
node.resolveRef = resolveRef;
// get and store current $id of node - this may be the same as parent $id
const currentId = resolveUri(node.parent?.$id, node.schema?.$id);
node.$id = currentId;
node.lastIdPointer = node.parent?.lastIdPointer ?? "#";
if (currentId !== node.parent?.$id && node.evaluationPath !== "#") {
node.lastIdPointer = node.evaluationPath;
}
// store this node for retrieval by $id + json-pointer from $id
if (node.lastIdPointer !== "#" && node.evaluationPath.startsWith(node.lastIdPointer)) {
const localPointer = `#${node.evaluationPath.replace(node.lastIdPointer, "")}`;
register(node, resolveUri(currentId, localPointer));
}
// store $rootId + json-pointer to this node
register(node, resolveUri(node.context.rootNode.$id, node.evaluationPath));
// @draft-2020: A $dynamicRef to a $dynamicAnchor in the same schema resource behaves like a normal $ref to an $anchor
const anchor = node.schema.$anchor;
if (anchor) {
// store this node for retrieval by $id + anchor
const anchorUrl = `${currentId.replace(/#$/, "")}#${anchor}`;
if (node.context.anchors[anchorUrl] == null) {
node.context.anchors[anchorUrl] = node;
}
}
const dynamicAnchor = node.schema.$dynamicAnchor;
if (dynamicAnchor) {
// store this node for retrieval by $id + anchor
const dynamicAnchorUrl = `${currentId.replace(/#$/, "")}#${dynamicAnchor}`;
if (node.context.dynamicAnchors[dynamicAnchorUrl] == null) {
node.context.dynamicAnchors[dynamicAnchorUrl] = node;
}
}
// precompile reference
if (node.schema.$ref) {
node.$ref = resolveUri(currentId, node.schema.$ref);
if (node.$ref.startsWith("/")) {
node.$ref = `#${node.$ref}`;
}
}
// validate simple ref to definitions
if (node.$ref?.startsWith("#/$defs/")) {
if (get(node.getNodeRoot().schema, node.$ref) == null) {
return node.createError("schema-error", {
pointer: `${node.schemaLocation}/$ref`,
schema: node.schema,
value: node.schema.$ref,
message: `Invalid $ref to missing target '${node.schema.ref}'`
});
}
}
}
export function reduceRef({ node, data, key, pointer, path }: JsonSchemaReducerParams) {
if (node == null) {
return;
}
const resolvedNode = node.resolveRef({ pointer, path });
if (resolvedNode == null) {
return node.createError("ref-error", {
ref: node.schema.$ref ?? node.schema.$dynamicRef,
pointer,
schema: node.schema,
value: data
});
}
if (resolvedNode.schemaLocation === node.schemaLocation) {
return resolvedNode;
}
const merged = mergeNode(node, resolvedNode) as SchemaNode;
const { node: reducedNode, error } = merged.reduceNode(data, { key, pointer, path });
return reducedNode ?? error;
}
export function resolveRef(this: SchemaNode, { pointer, path = [] }: { pointer?: string; path?: ValidationPath } = {}) {
if (this.schema.$dynamicRef) {
const nextNode = resolveRecursiveRef(this, path);
if (isJsonError(nextNode)) {
return nextNode;
}
path.push({ pointer: pointer!, node: nextNode! });
return nextNode;
}
if (this.$ref == null) {
return this;
}
const resolvedNode = getRef(this);
if (isSchemaNode(resolvedNode)) {
path.push({ pointer: pointer!, node: resolvedNode });
}
return resolvedNode;
}
function validateRef({ node, data, pointer = "#", path }: JsonSchemaValidatorParams) {
const nextNode = node.resolveRef({ pointer, path });
if (nextNode != null) {
// recursively resolveRef and validate
return validateNode(nextNode, data, pointer, path);
}
return node.createError("ref-error", {
ref: node.schema.$ref ?? node.schema.$dynamicRef,
pointer,
schema: node.schema,
value: data
});
}
// 1. https://json-schema.org/draft/2019-09/json-schema-core#scopes
function resolveRecursiveRef(node: SchemaNode, path: ValidationPath): SchemaNode | JsonError {
const history = path;
const refInCurrentScope = resolveUri(node.$id, node.schema.$dynamicRef);
// A $dynamicRef with a non-matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor
const nonMatchingDynamicAnchor = node.context.dynamicAnchors[refInCurrentScope] == null;
if (nonMatchingDynamicAnchor) {
if (node.context.anchors[refInCurrentScope]) {
return compileNext(node.context.anchors[refInCurrentScope], node);
}
}
for (const entry of history) {
// A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor resolves to the first $dynamicAnchor in the dynamic scope
if (entry.node.schema.$dynamicAnchor) {
return compileNext(entry.node, node);
}
// A $dynamicRef only stops at a $dynamicAnchor if it is in the same dynamic scope.
const refWithoutScope = node.schema.$dynamicRef.split("#").pop();
const ref = resolveUri(entry.node.$id, `#${refWithoutScope}`);
const anchorNode = node.context.dynamicAnchors[ref];
if (anchorNode) {
return compileNext(node.context.dynamicAnchors[ref], node);
}
}
// A $dynamicRef without a matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor
return getRef(node, refInCurrentScope);
}
export function compileNext(referencedNode: SchemaNode, sourceNode: SchemaNode) {
let referencedSchema = referencedNode.schema;
if (isObject(referencedNode.schema)) {
referencedSchema = {
...omit(referencedNode.schema, "$id"),
...pick(sourceNode.schema, ...settings.PROPERTIES_TO_MERGE)
};
}
return referencedNode.compileSchema(
referencedSchema,
`${sourceNode.evaluationPath}/$ref`,
referencedNode.schemaLocation
);
}
export function getRef(node: SchemaNode, $ref = node?.$ref): SchemaNode | JsonError {
if ($ref == null) {
return node;
}
// resolve $ref by json-evaluationPath
if (node.context.refs[$ref]) {
return compileNext(node.context.refs[$ref], node);
}
// resolve $ref from $anchor
if (node.context.anchors[$ref]) {
return compileNext(node.context.anchors[$ref], node);
}
// resolve $ref from $dynamicAnchor
if (node.context.dynamicAnchors[$ref]) {
// A $ref to a $dynamicAnchor in the same schema resource behaves like a normal $ref to an $anchor
return compileNext(node.context.dynamicAnchors[$ref], node);
}
// check for remote-host + pointer pair to switch rootSchema
const fragments = splitRef($ref);
if (fragments.length === 0) {
return node.createError("ref-error", {
ref: $ref,
pointer: node.evaluationPath,
schema: node.schema,
value: undefined
});
}
// resolve $ref as remote-host
if (fragments.length === 1) {
const $ref = fragments[0];
// this is a reference to remote-host root node
if (node.context.remotes[$ref]) {
return compileNext(node.context.remotes[$ref], node);
}
if ($ref[0] === "#") {
// support refOfUnknownKeyword
const rootSchema = node.context.rootNode.schema;
const targetSchema = get(rootSchema, $ref);
if (targetSchema) {
return node.compileSchema(targetSchema, `${node.evaluationPath}/$ref`, $ref);
}
}
// console.error("REF: UNFOUND 1", $ref);
return node.createError("ref-error", {
ref: $ref,
pointer: node.evaluationPath,
schema: node.schema,
value: undefined
});
}
if (fragments.length === 2) {
const $remoteHostRef = fragments[0];
// this is a reference to remote-host root node (and not a self reference)
if (node.context.remotes[$remoteHostRef] && node !== node.context.remotes[$remoteHostRef]) {
const referencedNode = node.context.remotes[$remoteHostRef];
// resolve full ref on remote schema - we store currently only store ref with domain
let nextNode = getRef(referencedNode, $ref);
if (nextNode) {
return nextNode;
}
// @note required for test spec 04
nextNode = getRef(referencedNode, fragments[1]);
if (nextNode) {
return nextNode;
}
}
// resolve by json-pointer (optional dynamicRef)
if (node.context.refs[$remoteHostRef]) {
const parentNode = node.context.refs[$remoteHostRef];
const path = split(fragments[1]);
// @todo add utility to resolve schema-pointer to schema
let currentNode = parentNode;
for (const item of path) {
const property = item === "definitions" ? "$defs" : item;
// @ts-expect-error random path
currentNode = currentNode[property];
if (currentNode == null) {
// console.error("REF: FAILED RESOLVING ref json-pointer", fragments[1]);
return node.createError("ref-error", {
ref: $ref,
pointer: node.evaluationPath,
schema: node.schema,
value: undefined,
host: fragments[0],
local: fragments[1]
});
}
}
return currentNode;
}
}
return node.createError("ref-error", {
ref: $ref,
pointer: node.evaluationPath,
schema: node.schema,
value: undefined
});
}