305 lines
9.5 KiB
JavaScript
305 lines
9.5 KiB
JavaScript
const Ajv = require('ajv');
|
|
const path = require('path');
|
|
const deepmerge = require('deepmerge');
|
|
const yaml = require('./yaml');
|
|
|
|
const MAGIC = 'c823d4d4';
|
|
|
|
const PRIMITIVE_DEFAULTS = {
|
|
'null': null,
|
|
'boolean': false,
|
|
'number': 0,
|
|
'integer': 0,
|
|
'string': '',
|
|
'array': [],
|
|
'object': {}
|
|
};
|
|
|
|
class DefaultValue {
|
|
constructor(value, description) {
|
|
this.value = value;
|
|
this.description = description;
|
|
}
|
|
|
|
merge(source) {
|
|
if ('description' in source && source.description) {
|
|
this.description = source.description;
|
|
}
|
|
if ('value' in source && source.value) {
|
|
if (this.value instanceof DefaultValue) {
|
|
this.value.merge(source.value);
|
|
} else if (Array.isArray(this.value) && Array.isArray(source.value)) {
|
|
this.value.concat(...source.value);
|
|
} else if (typeof this.value === 'object' && typeof source.value === 'object') {
|
|
for (const key in source.value) {
|
|
this.value[key] = source.value[key];
|
|
}
|
|
} else {
|
|
this.value = deepmerge(this.value, source.value);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
clone() {
|
|
const result = new DefaultValue(this.value, this.description);
|
|
if (result.value instanceof DefaultValue) {
|
|
result.value = result.value.clone();
|
|
} else if (Array.isArray(result.value)) {
|
|
result.value = [].concat(...result.value);
|
|
} else if (typeof result.value === 'object') {
|
|
result.value = Object.assign({}, result.value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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());
|
|
}
|
|
return [item];
|
|
}));
|
|
}
|
|
|
|
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 {
|
|
result[key] = item;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
toCommented() {
|
|
if (Array.isArray(this.value)) {
|
|
return this.toCommentedArray();
|
|
} else if (typeof this.value === 'object' && this.value !== null) {
|
|
return this.toCommentedObject();
|
|
}
|
|
return this.value;
|
|
}
|
|
|
|
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 */
|
|
class Schema {
|
|
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(obj) {
|
|
if (!this.compiledSchema) {
|
|
this.compiledSchema = this.loader.compileValidator(this.def.$id);
|
|
}
|
|
return this.compiledSchema(obj) ? true : this.compiledSchema.errors;
|
|
}
|
|
|
|
getArrayDefaultValue(def) {
|
|
let value;
|
|
const defaultValue = new DefaultValue(null, def.description);
|
|
if ('items' in def && typeof def.items === 'object') {
|
|
const items = Object.assign({}, def.items);
|
|
delete items.oneOf;
|
|
value = this.getDefaultValue(items);
|
|
}
|
|
if ('oneOf' in def.items && Array.isArray(def.items.oneOf)) {
|
|
defaultValue.value = def.items.oneOf.map(one => {
|
|
if (!(value instanceof DefaultValue)) {
|
|
return this.getDefaultValue(one);
|
|
}
|
|
return value.clone().merge(this.getDefaultValue(one));
|
|
});
|
|
} else {
|
|
if (!Array.isArray(value)) {
|
|
value = [value];
|
|
}
|
|
defaultValue.value = value;
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
getObjectDefaultValue(def) {
|
|
const value = {};
|
|
if ('properties' in def && typeof def.properties === 'object') {
|
|
for (const property in def.properties) {
|
|
value[property] = this.getDefaultValue(def.properties[property]);
|
|
}
|
|
}
|
|
const defaultValue = new DefaultValue(value, def.description);
|
|
if ('oneOf' in def && Array.isArray(def.oneOf) && def.oneOf.length) {
|
|
return defaultValue.merge(this.getDefaultValue(def.oneOf[0]));
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
getTypedDefaultValue(def) {
|
|
let defaultValue;
|
|
const type = Array.isArray(def.type) ? def.type[0] : def.type;
|
|
if (type === 'array') {
|
|
defaultValue = this.getArrayDefaultValue(def);
|
|
} else if (type === 'object') {
|
|
defaultValue = this.getObjectDefaultValue(def);
|
|
} else if (type in PRIMITIVE_DEFAULTS) {
|
|
if ('nullable' in def && 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 ('$ref' in def && def.$ref) {
|
|
defaultValue = this.getReferredDefaultValue(def).merge(defaultValue);
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
getReferredDefaultValue(def) {
|
|
const schema = this.loader.getSchema(def.$ref);
|
|
if (!schema) {
|
|
throw new Error(`Schema ${def.$ref} is not loaded`);
|
|
}
|
|
return this.getDefaultValue(schema.def).merge({ description: def.description });
|
|
}
|
|
|
|
getDefaultValue(def = null) {
|
|
if (!def) {
|
|
def = this.def;
|
|
}
|
|
if ('const' in def) {
|
|
return new DefaultValue(def.const, def.description);
|
|
}
|
|
if ('default' in def) {
|
|
return new DefaultValue(def.default, def.description);
|
|
}
|
|
if ('examples' in def && Array.isArray(def.examples) && def.examples.length) {
|
|
return new DefaultValue(def.examples[0], def.description);
|
|
}
|
|
if ('type' in def && def.type) {
|
|
return this.getTypedDefaultValue(def);
|
|
}
|
|
// $ref only schemas
|
|
if ('$ref' in def && def.$ref) {
|
|
return this.getReferredDefaultValue(def);
|
|
}
|
|
}
|
|
}
|
|
|
|
class SchemaLoader {
|
|
constructor() {
|
|
this.schemas = {};
|
|
this.ajv = new Ajv({ nullable: true });
|
|
}
|
|
|
|
getSchema($id) {
|
|
return this.schemas[$id];
|
|
}
|
|
|
|
addSchema(def) {
|
|
if (!Object.prototype.hasOwnProperty.call(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);
|
|
}
|
|
|
|
removeSchema($id) {
|
|
this.ajv.removeSchema($id);
|
|
delete this.schemas[$id];
|
|
}
|
|
|
|
compileValidator($id) {
|
|
return this.ajv.compile(this.schemas[$id].def);
|
|
}
|
|
}
|
|
|
|
function traverseObj(obj, targetKey, handler) {
|
|
if (Array.isArray(obj)) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SchemaLoader.load = (rootSchemaDef, resolveDirs = []) => {
|
|
if (!Array.isArray(resolveDirs)) {
|
|
resolveDirs = [resolveDirs];
|
|
}
|
|
|
|
const loader = new SchemaLoader();
|
|
loader.addSchema(rootSchemaDef);
|
|
|
|
function handler($ref) {
|
|
if (typeof $ref !== 'string') {
|
|
throw new Error('Invalid schema reference id: ' + JSON.stringify($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 = {
|
|
Schema,
|
|
SchemaLoader,
|
|
DefaultValue
|
|
};
|