/**
* Configuration and validation.
* @module core/schema
*/
const Ajv = require('ajv');
const path = require('path');
const yaml = require('../util/yaml');
/**
* A magic string for reformating comment lines in the YAML output.
*/
const MAGIC = 'c823d4d4';
/**
* Default value for the primitive types.
* @access private
*/
const PRIMITIVE_DEFAULTS = {
null: null,
boolean: false,
number: 0,
integer: 0,
string: '',
array: [],
object: {},
};
const typeOf = (value) => {
if (value === undefined) {
return 'undefined';
}
return Object.prototype.toString
.call(value)
.replace(/^\[object\s+([a-z]+)\]$/i, '$1')
.toLowerCase();
};
const hasOwnProperty = (obj, prop) => {
return obj === null || typeOf(obj) === 'undefined'
? false
: Object.prototype.hasOwnProperty.call(obj, prop);
};
function traverseObj(obj, targetKey, handler) {
if (typeOf(obj) === 'array') {
for (const child of obj) {
traverseObj(child, targetKey, handler);
}
} else if (typeOf(obj) === 'object') {
for (const key in obj) {
if (key === targetKey) {
handler(obj[key]);
} else {
traverseObj(obj[key], targetKey, handler);
}
}
}
}
/**
* The default value wrapper class.
*/
class DefaultValue {
/**
* @param {any} value The wrapped default value.
* @param {string} description A description of the contained value. Used to produce
* the comment string.
*/
constructor(value, description) {
this.value = value;
this.description = description;
}
/**
* Duplicate the current default value.
* <p>
* Wrapped value will be shallow copied.
*
* @returns {module:core/schema~DefaultValue} A new instance of the current default value.
*/
clone() {
const result = new DefaultValue(this.value, this.description);
if (result.value instanceof DefaultValue) {
result.value = result.value.clone();
} else if (typeOf(result.value) === 'array') {
result.value = [].concat(result.value);
} else if (typeOf(result.value) === 'object') {
result.value = Object.assign({}, result.value);
}
return result;
}
/**
* Unwrap current {module:core/schema~DefaultValue} if its wrapped value is also a
* {module:core/schema~DefaultValue}.
* <p>
* The description will also be replaced with wrapped value description, if exists.
*/
flatten() {
if (this.value instanceof DefaultValue) {
this.value.flatten();
if (hasOwnProperty(this.value, 'description') && this.value.description) {
this.description = this.value.description;
}
this.value = this.value.value;
}
}
/**
* Merge current default value with another default value.
*
* @param {module:core/schema~DefaultValue} source The input object to be merged with. Should have the
* same wrapped value type as current wrapped default value.
*/
merge(source) {
if (hasOwnProperty(source, 'value') && source.value !== null) {
this.flatten();
if (source.value instanceof DefaultValue) {
source = source.clone();
source.flatten();
}
if (typeOf(source.value) === typeOf(this.value)) {
if (typeOf(this.value) === 'array') {
this.value = this.value.concat(source.value);
} else if (typeOf(this.value) === 'object') {
Object.keys(source.value).forEach((key) => {
this.value[key] = source.value[key];
});
} else {
this.value = source.value;
}
} else if (typeOf(this.value) === 'undefined' || typeOf(this.value) === 'null') {
this.value = source.value;
}
}
if (hasOwnProperty(source, 'description') && source.description) {
this.description = source.description;
}
}
/**
* Create a new array with comments inserted as new elements right before each child element.
* <p>
* If current wrapped value is an array and some of its elements are instances of
* {@link module:core/schema~DefaultValue}, the element's description will be inserted as a new child
* right before the element.
* The element itself will also be converted into its commented version using the <code>toCommented</code>
* method.
*
* @returns {Array} A new array with comments and the original elements in the commented form.
*/
toCommentedArray() {
return [].concat(
...this.value.map((item) => {
if (item instanceof DefaultValue) {
if (typeOf(item.description) !== 'string' || !item.description.trim()) {
return [item.toCommented()];
}
return item.description
.split('\n')
.map((line, i) => {
return MAGIC + i + ': ' + line;
})
.concat(item.toCommented());
} else if (typeOf(item) === 'array' || typeOf(item) === 'object') {
return new DefaultValue(item).toCommented();
}
return [item];
}),
);
}
/**
* Create a new object with comments inserted as new properties right before every original properties.
* <p>
* Works similar to {@link module:core/schema~DefaultValue#toCommentedArray}.
*
* @returns {Object} A new object with comments and the original property values in the commented form.
*/
toCommentedObject() {
if (this.value instanceof DefaultValue) {
return this.value.toCommented();
}
const result = {};
for (const key in this.value) {
const item = this.value[key];
if (item instanceof DefaultValue) {
if (typeOf(item.description) === 'string' && item.description.trim()) {
item.description.split('\n').forEach((line, i) => {
result[MAGIC + key + i] = line;
});
}
result[key] = item.toCommented();
} else if (typeOf(item) === 'array' || typeOf(item) === 'object') {
result[key] = new DefaultValue(item).toCommented();
} else {
result[key] = item;
}
}
return result;
}
/**
* Call {@link module:core/schema~DefaultValue#toCommentedArray} or
* {@link module:core/schema~DefaultValue#toCommentedObject} based on the type of the wrapped value.
* <p>
* If neither applies, directly return the wrapped value.
*
* @returns {any} The commented object/array, or the original wrapped value.
*/
toCommented() {
if (typeOf(this.value) === 'array') {
return this.toCommentedArray();
} else if (typeOf(this.value) === 'object') {
return this.toCommentedObject();
}
return this.value;
}
/**
* Create the YAML string with all the comments from current default value.
*
* @returns {string} The formatted YAML string.
*/
toYaml() {
const regex = new RegExp("^(\\s*)(?:-\\s*\\')?" + MAGIC + ".*?:\\s*\\'?(.*?)\\'*$", 'mg');
return yaml.stringify(this.toCommented()).replace(regex, '$1# $2'); // restore comments
}
}
/* eslint-disable no-use-before-define */
/**
* A class that can resolving referenced JSON schemas and create {@link module:core/schema~DefaultValue}s.
*/
class Schema {
/**
* @param {module:core/schema~SchemaLoader} loader JSON schema loader.
* @param {Object} def The JSON schema definition.
*/
constructor(loader, def) {
if (!(loader instanceof SchemaLoader)) {
throw new Error('loader must be an instance of SchemaLoader');
}
if (typeOf(def) !== 'object') {
throw new Error('schema definition must be an object');
}
this.loader = loader;
this.def = def;
this.compiledSchema = null;
}
/**
* Validate an object against the JSON schema.
*
* @param {any} obj Object to be validated.
* @returns {boolean|Object} True if Object is valid, or validation errors.
*/
validate(obj) {
if (!this.compiledSchema) {
this.compiledSchema = this.loader.compileValidator(this.def.$id);
}
return this.compiledSchema(obj) ? true : this.compiledSchema.errors;
}
/**
* Create the {@link module:core/schema~DefaultValue} for an array-typed JSON schema.
* <p>
* Each possible type of the array elements defined in <code>oneOf</code> will be processed and a
* {@link module:core/schema~DefaultValue} will be added to the result array.
*
* @param {Object} def JSON schema definition of the array.
* @returns {@link module:core/schema~DefaultValue} The {@link module:core/schema~DefaultValue} for the
* array definition.
*/
getArrayDefaultValue(def) {
const defaultValue = new DefaultValue(null, def.description);
// all array elements have the same type, return the default value for that type
if (typeOf(def.items) === 'object') {
const items = Object.assign({}, def.items);
delete items.oneOf;
let value = this.getDefaultValue(items);
// additionally, if each array element can be in one of the provided types in <code>oneOf</code>
if (typeOf(def.items.oneOf) === 'array') {
// for each <code>oneOf</code> element, return a {@link module:core/schema~DefaultValue}
defaultValue.value = def.items.oneOf.map((one) => {
// if the <code>items</code> definition also exists, merge it with the <code>oneOf</code>
// {@link module:core/schema~DefaultValue}
const clone = value.clone();
clone.merge(this.getDefaultValue(one));
return clone;
});
} else {
if (typeOf(value) !== 'array') {
value = [value];
}
defaultValue.value = value;
}
}
return defaultValue;
}
/**
* Create the {@link module:core/schema~DefaultValue} for an object-typed JSON schema.
* <p>
* The first possible type of the object element defined in <code>oneOf</code> will be processed and its
* {@link module:core/schema~DefaultValue} will be merged with the {@link module:core/schema~DefaultValue}
* created from the <code>properties</code> definition.
*
* @param {Object} def JSON schema definition of the object.
* @returns {@link module:core/schema~DefaultValue} The {@link module:core/schema~DefaultValue} for the
* object definition.
*/
getObjectDefaultValue(def) {
const value = {};
if (typeOf(def.properties) === 'object') {
for (const property in def.properties) {
value[property] = this.getDefaultValue(def.properties[property]);
}
}
const defaultValue = new DefaultValue(value, def.description);
// only the first one of the possible types will be merged with {@link module:core/schema~DefaultValue}
// for the <code>properties</code> definition.
if (typeOf(def.oneOf) === 'array' && def.oneOf.length) {
defaultValue.merge(this.getDefaultValue(def.oneOf[0]));
defaultValue.description = def.description;
}
return defaultValue;
}
/**
* Create the {@link module:core/schema~DefaultValue} for any typed JSON schema that is not purely defined
* by its <code>$ref</code>.
* <p>
* If the definition also contains a <code>$ref</code>, the {@link module:core/schema~DefaultValue} for the
* <code>$ref</code> definition will be merged to the {@link module:core/schema~DefaultValue} of the current
* definition.
* <p>
* If <code>type</code> of the JSON schema is of primitive types, and the <code>nullable</code> is set to
* <code>false</code> in the schema definition, primitive default values will be set insided the result
* {@link module:core/schema~DefaultValue}.
*
* @param {Object} def JSON schema definition.
* @returns {@link module:core/schema~DefaultValue} The {@link module:core/schema~DefaultValue} for the
* definition.
* @throws {Error} If <code>type</code> is undefined or it is 'array', 'object', or primitive types.
*/
getTypedDefaultValue(def) {
let defaultValue;
const type = typeOf(def.type) === 'array' ? def.type[0] : def.type;
if (type === 'array') {
defaultValue = this.getArrayDefaultValue(def);
} else if (type === 'object') {
defaultValue = this.getObjectDefaultValue(def);
} else if (hasOwnProperty(PRIMITIVE_DEFAULTS, type)) {
if (hasOwnProperty(def, 'nullable') && def.nullable) {
defaultValue = new DefaultValue(null, def.description);
} else {
defaultValue = new DefaultValue(PRIMITIVE_DEFAULTS[type], def.description);
}
} else {
throw new Error(`Cannot get default value for type ${type}`);
}
// referred default value always get overwritten by its parent default value
if (hasOwnProperty(def, '$ref') && def.$ref) {
const refDefaultValue = this.getReferredDefaultValue(def);
refDefaultValue.merge(defaultValue);
defaultValue = refDefaultValue;
}
return defaultValue;
}
/**
* Create the {@link module:core/schema~DefaultValue} for any JSON schema that is purely defined by its
* <code>$ref</code>.
*
* @param {Object} def JSON schema definition.
* @returns {@link module:core/schema~DefaultValue} The {@link module:core/schema~DefaultValue} for the
* definition.
*/
getReferredDefaultValue(def) {
const schema = this.loader.getSchema(def.$ref);
if (!schema) {
throw new Error(`Schema ${def.$ref} is not loaded`);
}
const defaultValue = this.getDefaultValue(schema.def);
defaultValue.merge({ description: def.description });
return defaultValue;
}
/**
* Create the {@link module:core/schema~DefaultValue} for any JSON schema.
*
* @param {Object} def JSON schema definition.
* @returns {@link module:core/schema~DefaultValue} The {@link module:core/schema~DefaultValue} for the
* definition.
*/
getDefaultValue(def = null) {
if (!def) {
def = this.def;
}
if (hasOwnProperty(def, 'const')) {
return new DefaultValue(def.const, def.description);
}
if (hasOwnProperty(def, 'default')) {
return new DefaultValue(def.default, def.description);
}
if (
hasOwnProperty(def, 'examples') &&
typeOf(def.examples) === 'array' &&
def.examples.length
) {
return new DefaultValue(def.examples[0], def.description);
}
if (hasOwnProperty(def, 'type') && def.type) {
return this.getTypedDefaultValue(def);
}
// $ref only schemas
if (hasOwnProperty(def, '$ref') && def.$ref) {
return this.getReferredDefaultValue(def);
}
throw new Error(
'The following schema definition must have at least one of the ' +
'["const", "default", "examples", "type", "$ref"] fields:\n' +
JSON.stringify(def, null, 2),
);
}
}
/**
* Class for loading JSON schema files from filesystems and creating validator.
*/
class SchemaLoader {
constructor() {
this.schemas = {};
this.ajv = new Ajv({ allowUnionTypes: true });
}
/**
* Get JSON schema definition by its <code>$id</code>.
*
* @param {string} $id JSON schema <code>$id</code>.
* @returns {Object} JSON schema definition.
*/
getSchema($id) {
return this.schemas[$id];
}
/**
* Add a JSON schema definition to the collection.
*
* @param {Object} JSON schema definition.
* @throws {Error} If JSON schema definition does not have an <code>$id</code>.
*/
addSchema(def) {
if (!hasOwnProperty(def, '$id')) {
throw new Error('The schema definition does not have an $id field');
}
this.ajv.addSchema(def);
this.schemas[def.$id] = new Schema(this, def);
}
/**
* Remove a JSON schema definition to the collection.
*
* @param {string} $id JSON schema <code>$id</code>.
*/
removeSchema($id) {
this.ajv.removeSchema($id);
delete this.schemas[$id];
}
/**
* Create a JSON schema validation function for a given schema identified by its <code>$id</code>.
*
* @param {*} $id $id JSON schema <code>$id</code>.
* @returns {Function} JSON schema validation function.
*/
compileValidator($id) {
return this.ajv.compile(this.getSchema($id).def);
}
}
/**
* Create a {@link module:core/schema~SchemaLoader} and load all referred JSON schema from the resolve
* directories.
*
* @param {Object} rootSchemaDef The root JSON schema definition.
* @param {Array} resolveDirs Additional directories for resolving referred JSON schemas besides the
* <code>lib/schema</code> folder in this library. Directory order matters.
* @returns {@link module:core/schema~SchemaLoader} The schema loader with all referred schemas loaded.
* @throws {Error} Referred schema not found in any of the resolve directories.
*/
SchemaLoader.load = (rootSchemaDef, resolveDirs = []) => {
if (typeOf(resolveDirs) !== 'array') {
resolveDirs = [resolveDirs];
}
resolveDirs.push(path.join(__dirname, '../schema/'));
const loader = new SchemaLoader();
loader.addSchema(rootSchemaDef);
function handler($ref) {
if (loader.getSchema($ref)) {
return;
}
for (const dir of resolveDirs) {
let def;
try {
def = require(path.join(dir, $ref));
} catch (e) {
continue;
}
if (typeOf(def) !== 'object' || def.$id !== $ref) {
continue;
}
loader.addSchema(def);
traverseObj(def, '$ref', handler);
return;
}
throw new Error(
'Cannot find schema definition ' +
$ref +
'.\n' +
'Please check if the file exists and its $id is correct',
);
}
traverseObj(rootSchemaDef, '$ref', handler);
return loader;
};
module.exports = {
MAGIC,
Schema,
SchemaLoader,
DefaultValue,
};