json-schema-library
Version:
Customizable and hackable json-validator and json-schema utilities for traversal, data generation and validation
370 lines (329 loc) • 12 kB
text/typescript
import {
Keyword,
JsonSchemaReducerParams,
JsonSchemaValidatorParams,
ValidationPath,
ValidationReturnType,
ValidationAnnotation
} from "../Keyword";
import { isSchemaNode, SchemaNode } from "../types";
import settings from "../settings";
import { getValue } from "../utils/getValue";
import sanitizeErrors from "../utils/sanitizeErrors";
import { isObject } from "../utils/isObject";
import { validateNode } from "../validateNode";
import { joinDynamicId } from "../SchemaNode";
import { collectValidationErrors } from "src/utils/collectValidationErrors";
const KEYWORD = "oneOf";
const { DECLARATOR_ONEOF } = settings;
export const oneOfKeyword: Keyword = {
id: KEYWORD,
keyword: KEYWORD,
parse: parseOneOf,
addReduce: (node) => node[KEYWORD] != null,
reduce: reduceOneOf,
addValidate: (node) => node[KEYWORD] != null,
validate: oneOfValidator
};
export const oneOfFuzzyKeyword: Keyword = {
id: "oneOf-fuzzy",
keyword: "oneOf",
parse: parseOneOf,
addReduce: (node) => node.oneOf != null,
reduce: reduceOneOfFuzzy,
addValidate: (node) => node.oneOf != null,
validate: oneOfValidator
};
export function parseOneOf(node: SchemaNode) {
const { schema, evaluationPath, schemaLocation } = node;
if (schema[KEYWORD] == null) {
return;
}
if (!Array.isArray(schema[KEYWORD])) {
return node.createError("schema-error", {
pointer: schemaLocation,
schema,
value: schema[KEYWORD],
message: `Keyword '${KEYWORD}' must be an array - received '${typeof schema[KEYWORD]}'`
});
}
if (schema[KEYWORD].length === 0) {
return;
}
node[KEYWORD] = schema[KEYWORD].map((s, index) =>
node.compileSchema(s, `${evaluationPath}/${KEYWORD}/${index}`, `${schemaLocation}/${KEYWORD}/${index}`)
);
return collectValidationErrors([], ...node[KEYWORD]);
}
function reduceOneOf({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
if (node.oneOf == null) {
return;
}
// !keyword: oneOfProperty
// an additional <DECLARATOR_ONEOF> (default `oneOfProperty`) on the schema will exactly determine the
// oneOf value (if set in data)
if (data != null && node.schema[DECLARATOR_ONEOF]) {
return reduceOneOfDeclarator({ node, data, pointer, path });
}
const matches: { index: number; node: SchemaNode }[] = [];
const errors: ValidationReturnType[] = [];
for (let i = 0; i < node.oneOf.length; i += 1) {
const validationErrors = validateNode(node.oneOf[i], data, pointer, path);
if (validationErrors.length === 0) {
matches.push({ index: i, node: node.oneOf[i] });
} else {
errors.push(...validationErrors);
}
}
if (matches.length === 1) {
const { node, index } = matches[0];
const { node: reducedNode, error } = node.reduceNode(data, { pointer, path });
if (reducedNode) {
const nestedDynamicId = reducedNode.dynamicId?.replace(node.dynamicId, "") ?? "";
const dynamicId = nestedDynamicId === "" ? `oneOf/${index}` : nestedDynamicId;
reducedNode.oneOfIndex = index; // @evaluation-info
reducedNode.dynamicId = joinDynamicId(reducedNode.dynamicId, `+${node.schemaLocation}(${dynamicId})`);
return reducedNode;
}
return error;
}
if (matches.length === 0) {
return node.createError("one-of-error", {
value: JSON.stringify(data),
pointer,
schema: node.schema,
oneOf: node.schema.oneOf,
errors
});
}
return node.createError("one-of-error", {
value: JSON.stringify(data),
pointer,
schema: node.schema,
oneOf: node.schema.oneOf,
errors
});
}
/**
* Returns matching oneOf schema identified by matching schema for oneOfProperty
*/
export function reduceOneOfDeclarator({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
if (node.oneOf == null) {
return;
}
const oneOfProperty = node.schema[DECLARATOR_ONEOF];
const oneOfPropertyValue = getValue(data, oneOfProperty);
// in this case, we also fail when data undefined as this always is valid,
// but not in context on an expected oneOfProperty
if (data === undefined || oneOfPropertyValue === undefined) {
return node.createError("missing-one-of-property-error", {
oneOfProperty,
pointer,
schema: node.schema,
value: data
});
}
// find oneOf schema that has a matching oneOfProperty to the current input data
// TODO throw an error if multiple matches were found
const errors: ValidationReturnType = [];
for (let i = 0; i < node.oneOf.length; i += 1) {
const { node: resultNode } = node.oneOf[i].getNodeChild(oneOfProperty, data);
if (!isSchemaNode(resultNode)) {
// one of the oneOf schemas has a missing oneOfTypeProperty
// TODO this still might succeed
// TODO there is a possibility this throws an invalid error as we use input data
return node.createError("missing-one-of-declarator-error", {
declarator: DECLARATOR_ONEOF,
oneOfProperty,
schemaLocation: node.oneOf[i].schemaLocation,
pointer: `${pointer}/oneOf/${i}`,
schema: node.schema,
value: data
});
}
// collect errors in case we fail finding a matching schema
const result = sanitizeErrors(
validateNode(resultNode, oneOfPropertyValue, `${pointer}/${oneOfProperty}`, path)
);
if (result.length > 0) {
errors.push(...result);
} else {
// return at once when we found a schema
// TODO should check all oneOf-schema
const { node: reducedNode } = node.oneOf[i].reduceNode(data, { pointer, path });
if (reducedNode) {
reducedNode.oneOfIndex = i; // @evaluation-info
return reducedNode;
}
}
}
return node.createError("one-of-property-error", {
property: oneOfProperty,
value: data,
pointer,
schema: node.schema,
errors
});
}
/**
* Returns a ranking for the data and given schema
*
* @param draft
* @param - json schema type: object
* @param data
* @param [pointer]
* @return ranking value (higher is better)
*/
function fuzzyObjectValue(node: SchemaNode, data: Record<string, unknown>, pointer: string, path: ValidationPath) {
if (data == null || node.properties == null) {
return -1;
}
let value = 0;
const keys = Object.keys(node.properties ?? {});
for (const key of keys) {
if (data[key]) {
if (validateNode(node.properties[key], data[key], pointer, path).length === 0) {
value += 1;
}
}
}
return value;
}
/**
* Selects and returns a oneOf schema for the given data
*
* @param draft
* @param data
* @param [schema] - current json schema containing property oneOf
* @param [pointer] - json pointer to data
* @return oneOf schema or an error
*/
export function reduceOneOfFuzzy({ node, data, pointer, path }: Omit<JsonSchemaReducerParams, "key">) {
// @todo: usingMergeNode may add reducers that are no longer available
if (node.oneOf == null) {
return node;
}
const oneOfResult = reduceOneOf({ node, data, pointer, path });
if (isSchemaNode(oneOfResult)) {
return oneOfResult;
}
// fuzzy match oneOf
if (isObject(data)) {
let nodeOfItem: SchemaNode | undefined;
let schemaOfIndex = -1;
let fuzzyGreatest = 0;
for (let i = 0; i < node.oneOf.length; i += 1) {
const oneNode = node.oneOf[i];
const fuzzyValue = fuzzyObjectValue(oneNode, data, pointer, path);
if (fuzzyGreatest < fuzzyValue) {
fuzzyGreatest = fuzzyValue;
nodeOfItem = oneNode;
schemaOfIndex = i;
}
}
if (nodeOfItem === undefined) {
return node.createError("one-of-error", {
value: JSON.stringify(data),
pointer,
schema: node.schema,
oneOf: node.schema.oneOf
});
}
const { node: reducedNode, error } = nodeOfItem.reduceNode(data, { pointer, path });
if (reducedNode) {
reducedNode.oneOfIndex = schemaOfIndex; // @evaluation-info
return reducedNode;
}
return error;
}
return oneOfResult;
}
function validateFromDeclarator({ node, data, pointer = "#", path }: JsonSchemaValidatorParams) {
const { oneOf, schema } = node;
if (!oneOf) {
return;
}
// with a declarator we only validate by a declarator to retrieve matches.
// - if a single match was found, we return validation errors if any
// - if no match was found we return a one-of-error
// - if multiples matches were found we return a multiple-one-of-error
const oneOfProperty = schema[DECLARATOR_ONEOF];
const oneOfValue = getValue(data, oneOfProperty);
const matches: { index: number; node: SchemaNode }[] = [];
const errors: ValidationReturnType = [];
for (const oneOfNode of oneOf) {
const { node: oneOfPropertyNode, error } = oneOfNode.getNodeChild(oneOfProperty, oneOfValue);
if (oneOfPropertyNode) {
const validationResult = validateNode(oneOfPropertyNode, oneOfValue, `${pointer}/${oneOfProperty}`, path);
if (validationResult.length > 0) {
errors.push(...validationResult);
} else {
matches.push({ index: oneOf.indexOf(oneOfNode), node: oneOfNode });
}
} else {
console.log(
`jlib oneOf error: failed getting schema for '${oneOfProperty}' to resolve ${DECLARATOR_ONEOF} in ${pointer}/oneOf/${oneOf.indexOf(oneOfNode)}`,
error
);
}
}
if (matches.length === 1) {
const match = matches[0];
match.node.oneOfIndex = match.index; // @evaluation-info
return validateNode(match.node, data, pointer, path);
}
if (matches.length > 1) {
return node.createError("multiple-one-of-error", {
value: data,
pointer,
schema,
matches
});
}
return node.createError("one-of-error", {
value: JSON.stringify(data),
pointer,
schema,
oneOf: schema.oneOf,
errors
});
}
function oneOfValidator({ node, data, pointer = "#", path }: JsonSchemaValidatorParams) {
const { oneOf, schema } = node;
if (!oneOf) {
return;
}
if (schema[DECLARATOR_ONEOF]) {
return validateFromDeclarator({ node, data, pointer, path });
}
const matches: { index: number; node: SchemaNode }[] = [];
const errors: ValidationReturnType = [];
for (let i = 0; i < oneOf.length; i += 1) {
const validationResult = validateNode(oneOf[i], data, pointer, path);
if (validationResult.length > 0) {
errors.push(...validationResult);
} else {
matches.push({ index: i, node: oneOf[i] });
}
}
if (matches.length === 1) {
const { node, index } = matches[0];
node.oneOfIndex = index; // @evaluation-info
return undefined;
}
if (matches.length > 1) {
return node.createError("multiple-one-of-error", {
value: data,
pointer,
schema,
matches
});
}
return node.createError("one-of-error", {
value: JSON.stringify(data),
pointer,
schema,
oneOf: schema.oneOf,
errors
});
}