schema-fun
Version:
JSON schema tools
451 lines • 22.3 kB
JavaScript
import _ from 'lodash';
import { SchemaError } from './errors.js';
import { resolveRefs } from './json-refs.js';
import { pathDepth, propertyPathToArray, propertyPathToDottedPath } from './paths.js';
/**
* Expand references to other schemas.
*
* The returned schema may contain circular references and is therefore not serializable as JSON without special
* handling.
*
* @param schema The schema to expand.
* @param resolveSchemaRef A function that resolves references to other schemas.
* @returns A new schema without schema references. It may contain circular references.
*/
export async function expandSchemaReferences(schema, resolveSchemaRef) {
return await resolveRefs(schema, {
// Only resolve references to other schemas. Ignore '$ref' when it occurs as the name of a property in an object
// schema.
filter: (_refDetails, path) => {
return path[path.length - 1] != 'properties';
},
hooks: {
beforeLoad: (uri) => {
return {
result: resolveSchemaRef(uri),
continueLoading: false
};
}
},
refPostProcessor: (obj) => {
if (_.isString(obj)) {
return resolveSchemaRef(obj);
}
return obj;
},
resolveCirculars: true
});
}
/**
* Find one property in a schema, traversing referenced schemas if necessary.
*
* @param schema The schema to search.
* @param path The property path to find, in dot-and-bracket notation or as an array.
* @param resolveSchemaRef A function that resolves references to other schemas.
* @returns The property schema if found, or else undefined.
*/
export function findPropertyInSchema(schema, path, resolveSchemaRef) {
const pathArr = propertyPathToArray(path);
if (schema.$ref) {
const referencedSchema = resolveSchemaRef(schema.$ref);
if (!referencedSchema) {
// TODO Log error?
return undefined;
}
return findPropertyInSchema(referencedSchema, pathArr, resolveSchemaRef);
}
if (pathArr.length == 0) {
return schema;
}
if (schema.allOf) {
// Look in each schema option. Start with the last schema option; the options shouldn't have properties in common,
// but if they do, we give the last one precedence.
for (let schemaOption of _.reverse(schema.allOf)) {
// If the schema option is a reference, resolve it.
while (schemaOption && schemaOption.$ref) {
schemaOption = resolveSchemaRef(schemaOption.$ref);
}
if (!schemaOption) {
// Log error?
}
else {
const result = findPropertyInSchema(schemaOption, pathArr, resolveSchemaRef);
if (result) {
return result;
}
}
}
return undefined;
}
else if (schema.oneOf) {
// Look in each schema option. Return the first match.
for (let schemaOption of schema.oneOf) {
// If the schema option is a reference, resolve it.
while (schemaOption && schemaOption.$ref) {
schemaOption = resolveSchemaRef(schemaOption.$ref);
}
if (!schemaOption) {
// Log error?
}
else {
const result = findPropertyInSchema(schemaOption, pathArr, resolveSchemaRef);
if (result) {
return result;
}
}
}
return undefined;
}
else {
switch (schema.type) {
case 'object': {
const subschema = _.get(schema, ['properties', pathArr[0]], null);
if (pathArr.length == 1 || subschema == null) {
return subschema;
}
else {
return findPropertyInSchema(subschema, _.slice(pathArr, 1), resolveSchemaRef);
}
}
case 'array': {
if (!_.isInteger(pathArr[0])) {
return undefined;
}
const subschema = _.get(schema, ['items'], null);
if (subschema == null) {
// TODO Warn about missing items in schema
return undefined;
}
else {
return findPropertyInSchema(subschema, _.slice(pathArr, 1), resolveSchemaRef);
}
}
default:
// TODO Warn that we're trying to find a property in a non-object schema.
return undefined;
}
}
}
/**
* Find relationships in a schema.
*
* A relationship is a schema node that has a storage property; it describes a relationship between two documents. It
* typically has a $ref property that refers to a different schema, but this is not necessary; the related document
* type's schema may be embedded in the parent schema. The storage property indicates whether related documents are
* stored
* - As references,
* - As inverse references
* - Or inline, as copies of the related documents.
* The list of relationships returned may optionally be limited to specified storage types using the allowedStorage
* parameter.
*
* Relationships make sense in the context of some mechanism for finding or referring to documents. We call this a
* storage mechanism; it may be a SQL or NoSQL database, a REST API, or something else.
*
* If limitToPath is set, this function will only traverse path through the schema hierarchy. Otherwise, it will find
* all relationships.
*
* Since relationships my be cyclical, this function stops traversing the schema hierarchy whenever either
* - The end of limitToPath is reached,
* - limitToPath is not set and a specified maxDepth is reached,
* - Or limitToPath is not set and the function encounters a schema node it has already visited.
*
* @param schema The schema in which to look for relationships.
* @param resolveSchemaRef A function that resolves references to other schemas.
* @param allowedStorage A list of storage classes. If specified, relationships with other storage classes are ignored.
* @param limitToPath A property path (in dot-and-bracket notation or as an array) to traverse. If specified, only
* relationships in this path will be returned.
* @param maxDepth A maximum depth to traverse. Ignored if limitToPath is set.
* @param currentPath A parameter used when the function calls itself recursively. Should not be set by other callers.
* @param nodesTraversedInPath A parameter used when the function calls itself recursively. Should not be set by other
* callers.
* @param depthFromParent A parameter used when the function calls itself recursively. Should not be set by other
* callers.
* @returns A list of relationships contained in the schema.
*/
export function findRelationshipsInSchema(schema, resolveSchemaRef, allowedStorage, limitToPath, maxDepth = undefined, currentPath = [], nodesTraversedInPath = [], depthFromParent = 0) {
const currentPathArr = propertyPathToArray(currentPath);
const limitToPathArr = limitToPath ? propertyPathToArray(limitToPath) : undefined;
let relationships = [];
// Note any relationship properties specified by the current schema node.
let relationshipProperties = {
entityTypeName: schema.entityType,
foreignKeyPath: schema.foreignKey,
schemaRef: schema.$ref,
storage: schema.storage
};
// If this schema node has a reference to another schema, get the referenced schema. Except for any relationship
// properties noted above from the current schema node, we will subsequently work only with the referenced schema.
// Relationship properties from the original schema node take precedence over relationship properties from hthe
// referenced schema.
while (schema.$ref) {
const referencedSchema = resolveSchemaRef(schema.$ref);
if (!referencedSchema) {
// TODO Log error?
return [];
}
relationshipProperties = {
entityTypeName: referencedSchema.entityType,
foreignKeyPath: referencedSchema.foreignKey,
schemaRef: referencedSchema.$ref,
storage: referencedSchema.storage,
...relationshipProperties // Referencing schema takes precedence.
};
schema = referencedSchema;
}
// Check whether the current schema node is a relationship. If it is, add it to the list of relationships.
const relationshipIsReference = relationshipProperties.storage && ['ref', 'inverse-ref'].includes(relationshipProperties.storage);
if (relationshipProperties.storage && (!allowedStorage || allowedStorage.includes(relationshipProperties.storage))) {
const relationship = {
path: propertyPathToDottedPath(currentPathArr),
toMany: _.last(currentPathArr) == -1,
storage: relationshipProperties.storage || 'copy',
entityTypeName: relationshipProperties.entityTypeName,
schemaRef: relationshipProperties.schemaRef,
schema,
depthFromParent
};
if (relationshipProperties.storage == 'inverse-ref') {
if (!relationshipProperties.foreignKeyPath) {
// TODO Include the current location in the logged error.
throw new SchemaError(`Missing foreign key path in relationship with storage type inverse-ref`);
}
relationship.foreignKeyPath = relationshipProperties.foreignKeyPath;
}
relationships.push(relationship);
depthFromParent = 0;
}
// Limit the tree traversal depth.
if (maxDepth == undefined && nodesTraversedInPath.includes(schema)) {
// If no maximum depth was specified, do not traverse circular references.
// TODO This does not seem to work. nodesTraversedInPath.includes(schema) isn't catching the circularity.
return [];
}
else if (maxDepth != undefined && pathDepth(currentPathArr) > maxDepth) {
// If we have exceeded the maximum depth, stop traversing the schema.
return [];
}
const schemaType = schema.type;
const schemaOptions = schema.allOf || schema.oneOf;
if (schemaOptions && _.isArray(schemaOptions)) {
// The schema node has allOf or oneOf set. Call findRelationships on each schema option.
for (const schemaOption of schemaOptions) {
relationships = relationships.concat(findRelationshipsInSchema(schemaOption, resolveSchemaRef, allowedStorage, limitToPathArr, maxDepth, currentPathArr, [...nodesTraversedInPath, schema], depthFromParent));
}
}
else {
switch (schemaType) {
case 'object':
{
// The current schema node is an object schema. Traverse its properties. If limitToPath is set, only traverse
// one property (the first one in limitToPath) or none if limitToPath is empty or its first property does not
// exist in the schema.
const propertySchemas = _.get(schema, ['properties'], []);
let propertiesToTraverse = _.keys(propertySchemas);
if (limitToPathArr) {
const propertyToTraverse = limitToPathArr[0];
propertiesToTraverse =
_.isString(propertyToTraverse) && propertiesToTraverse.includes(propertyToTraverse)
? [propertyToTraverse]
: [];
}
for (const property of propertiesToTraverse) {
const subschema = propertySchemas[property];
relationships = relationships.concat(findRelationshipsInSchema(subschema, resolveSchemaRef, allowedStorage, limitToPathArr ? limitToPathArr.slice(1) : undefined, maxDepth, [...currentPathArr, property], [...nodesTraversedInPath, schema], relationshipIsReference ? 0 : depthFromParent + 1));
}
}
break;
case 'array':
// The current schema node is an array schema. Move on to its array element ("items") schema. If limitToPath is
// set, stop unless limitToPath has at least one path component. The first path component should be set to '*'
// or a number, but we don't validate it.
{
if (!limitToPathArr || limitToPathArr.length > 0) {
const itemsSchema = schema.items;
if (itemsSchema) {
relationships = relationships.concat(findRelationshipsInSchema(itemsSchema, resolveSchemaRef, allowedStorage, limitToPathArr ? limitToPathArr.slice(1) : undefined, maxDepth, [...currentPathArr, -1], [...nodesTraversedInPath, schema], depthFromParent + 1));
}
}
}
break;
default:
break;
}
}
return relationships;
}
/**
* List all the transient properties of a schema.
*
* Transient properties are identified by the custom JSON schema attribute "transient" being set to true.
*
* If limitToPath is set, this function will only traverse path through the schema hierarchy. Otherwise, it will find
* all relationships.
*
* Since relationships my be cyclical, this function stops traversing the schema hierarchy whenever either
* - The end of limitToPath is reached,
* - limitToPath is not set and a specified maxDepth is reached,
* - Or limitToPath is not set and the function encounters a schema node it has already visited.
*
* @param schema The schema in which to look for transient properties.
* @param resolveSchemaRef A function that resolves references to other schemas.
* @param limitToPath A property path (in dot-and-bracket notation or as an array) to traverse. If specified, only
* relationships in this path will be returned.
* @param maxDepth A maximum depth to traverse. Ignored if limitToPath is set.
* @param currentPath A parameter used when the function calls itself recursively. Should not be set by other callers.
* @param nodesTraversedInPath A parameter used when the function calls itself recursively. Should not be set by other
* callers.
* @returns An array of transient property paths in dot-and-bracket notation
*/
export function findTransientPropertiesInSchema(schema, resolveSchemaRef, limitToPath, maxDepth = undefined, currentPath = [], nodesTraversedInPath = []) {
const currentPathArr = propertyPathToArray(currentPath);
const limitToPathArr = limitToPath ? propertyPathToArray(limitToPath) : undefined;
let transientPropertyPaths = [];
// Note whether the current schema node specifies transience. This will take precedence over any referenced schema's
// transience.
let transient = schema.transient;
// If this schema node has a reference to another schema, get the referenced schema. Except for any transience noted
// above from the current schema node, we will subsequently work only with the referenced schema.
while (schema.$ref) {
const referencedSchema = resolveSchemaRef(schema.$ref);
if (!referencedSchema) {
// TODO Log error?
return [];
}
if (transient === undefined) {
// Referencing schema takes precedence.
transient = referencedSchema.transient;
}
schema = referencedSchema;
}
// Limit the tree traversal depth.
if (maxDepth == undefined && nodesTraversedInPath.includes(schema)) {
// If no maximum depth was specified, do not traverse circular references.
// TODO This does not seem to work. nodesTraversedInPath.includes(schema) isn't catching the circularity.
return [];
}
else if (maxDepth != undefined && pathDepth(currentPathArr) > maxDepth) {
// If we have exceeded the maximum depth, stop traversing the schema.
return [];
}
// Add the current path to the result if this schema node is marked transient.
if (pathDepth(currentPathArr) == 0) {
// The root of a schema cannot be transient.
}
else if (schema.transient) {
transientPropertyPaths.push(propertyPathToDottedPath(currentPathArr));
}
const schemaType = schema.type;
const schemaOptions = schema.allOf || schema.oneOf;
if (schemaOptions && _.isArray(schemaOptions)) {
// The schema node has allOf or oneOf set. Call findTransientPropertiesInSchema on each schema option.
// TODO Are we wrongly assuming that transient is not set on root nodes of schema options?
for (const schemaOption of schemaOptions) {
transientPropertyPaths = transientPropertyPaths.concat(findTransientPropertiesInSchema(schemaOption, resolveSchemaRef, limitToPathArr, maxDepth, currentPathArr, [
...nodesTraversedInPath,
schema
]));
}
}
else {
switch (schemaType) {
case 'object':
{
// Traverse the object schema's properties. If limitToPath is set, only traverse one property (the first one
// in limitToPath) or none if limitToPath is empty or its first property does not exist in the schema.
const propertySchemas = _.get(schema, ['properties'], []);
let propertiesToTraverse = _.keys(propertySchemas);
if (limitToPathArr) {
const propertyToTraverse = limitToPathArr[0];
propertiesToTraverse =
_.isString(propertyToTraverse) && propertiesToTraverse.includes(propertyToTraverse)
? [propertyToTraverse]
: [];
}
for (const property of propertiesToTraverse) {
const subschema = propertySchemas[property];
transientPropertyPaths = transientPropertyPaths.concat(findTransientPropertiesInSchema(subschema, resolveSchemaRef, limitToPathArr ? limitToPathArr.slice(1) : undefined, maxDepth, [...currentPathArr, property], [...nodesTraversedInPath, schema]));
}
}
break;
case 'array':
// The current schema node is an array schema. Move on to its array element ("items") schema. If limitToPath is
// set, stop unless limitToPath has at least one path component. The first path component should be set to '*'
// or a number, but we don't validate it.
{
if (!limitToPathArr || limitToPathArr.length > 0) {
const itemsSchema = schema.items;
if (itemsSchema) {
transientPropertyPaths = transientPropertyPaths.concat(findTransientPropertiesInSchema(itemsSchema, resolveSchemaRef, limitToPathArr ? limitToPathArr.slice(1) : undefined, maxDepth, [...currentPathArr, -1], [...nodesTraversedInPath, schema]));
}
}
}
break;
default:
break;
}
}
return transientPropertyPaths;
}
/**
* Determine whether a property is required by a schema.
*
* A property is required if all of its ancestors are required. If any ancestor is optional, the property is optional.
* Therefore, calling this function on a subschema may produca a different result than calling it on the parent schema.
*
* "Required" status is not recorded in the property itself but in its parent object schema.
*
* For efficiency, this function does not always check that the specified property exists. It only tranverses the schema
* until some non-required ancestor is encountered. If the property does not exist, this function will return false.
*
* @param schema The schema to search.
* @param path The property path to check, in dot-and-bracket notation or as an array.
* @param resolveSchemaRef A function that resolves references to other schemas.
* @returns `true` if the property is required, or `false` if it is optional or does not exist.
*/
export function propertyIsRequiredInSchema(schema, path, resolveSchemaRef) {
const pathArr = propertyPathToArray(path);
if (schema.$ref) {
const referencedSchema = resolveSchemaRef(schema.$ref);
if (!referencedSchema) {
// TODO Log error?
return false;
}
return propertyIsRequiredInSchema(referencedSchema, pathArr, resolveSchemaRef);
}
const schemaOptions = schema.allOf || schema.oneOf;
if (schemaOptions && _.isArray(schemaOptions)) {
// The schema node has allOf or oneOf set. Check whether any schema option requires the property.
for (const schemaOption of schemaOptions) {
if (propertyIsRequiredInSchema(schemaOption, path, resolveSchemaRef)) {
return true;
}
}
}
else {
switch (schema.type) {
case 'object': {
if (!(schema.required || []).includes(pathArr[0])) {
return false;
}
const subschema = _.get(schema, ['properties', pathArr[0]], null);
if (pathArr.length == 1 || subschema == null) {
return true;
}
else {
return propertyIsRequiredInSchema(subschema, _.slice(pathArr, 1), resolveSchemaRef);
}
}
case 'array':
if (pathArr.length == 1) {
// Array entries are never required.
// TODO Warn that we're checking whether an array entry is required?
return false;
}
return true;
default:
return true;
}
}
}
//# sourceMappingURL=schemas.js.map