import _ from 'lodash'
import { App, Hook } from 'adapt-authoring-core'
import fs from 'fs/promises'
import xss from 'xss'
/** @ignore */ const BASE_SCHEMA_NAME = 'base'
/**
* Functionality related to JSON schema
* @memberof jsonschema
*/
class JsonSchema {
constructor ({ enableCache, filePath, validator, xssWhitelist }) {
/**
* The raw built JSON schema
* @type {Object}
*/
this.built = undefined
/**
* The compiled schema validation function
* @type {function}
*/
this.compiled = undefined
/**
* Whether caching is enabled for this schema
* @type {Boolean}
*/
this.enableCache = enableCache
/**
* List of extensions for this schema
* @type {Array<String>}
*/
this.extensions = []
/**
* File path to the schema
* @type {String}
*/
this.filePath = filePath
/**
* Whether the schema is currently building
* @type {Boolean}
*/
this.isBuilding = false
/**
* The last build time (in milliseconds)
* @type {Number}
*/
this.lastBuildTime = undefined
/**
* The raw schema data for this schema (with no inheritance/extensions)
* @type {Object}
*/
this.raw = undefined
/**
* Reference to the Ajv validator instance
* @type {external:Ajv}
*/
this.validator = validator
/**
* Reference to the local XSS sanitiser instance
* @type {Object}
*/
this.xss = new xss.FilterXSS({ whiteList: xssWhitelist })
/**
* Hook which invokes every time the schema is built
* @type {Hook}
*/
this.buildHook = new Hook()
}
/**
* Determines whether the current schema build is valid using last modification timestamp
* @returns {Boolean}
*/
async isBuildValid () {
if (!this.built) return false
let schema = this
while (schema) {
const { mtimeMs } = await fs.stat(schema.filePath)
if (mtimeMs > this.lastBuildTime) return false
schema = await schema.getParent()
}
return true
}
/**
* Returs the parent schema if $merge is defined (or the base schema if a root schema)
* @returns {JsonSchema}
*/
async getParent () {
if (this.name === BASE_SCHEMA_NAME) return
const jsonschema = await App.instance.waitForModule('jsonschema')
const mergeRef = this.raw?.$merge?.source?.$ref
try {
return await jsonschema.getSchema(mergeRef ?? BASE_SCHEMA_NAME)
} catch (e) {}
}
/**
* Loads the schema file
* @returns {JsonSchema} This instance
*/
async load () {
try {
this.raw = JSON.parse((await fs.readFile(this.filePath)).toString())
this.name = this.raw.$anchor
} catch (e) {
throw App.instance.errors?.SCHEMA_LOAD_FAILED?.setData({ schemaName: this.filePath }) ?? e
}
if (this.validator.validateSchema(this.raw)?.errors) {
const errors = this.validator.errors.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
if (errors.length) {
throw App.instance.errors.INVALID_SCHEMA
.setData({ schemaName: this.name, errors: errors.join(', ') })
}
}
return this
}
/**
* Builds and compiles the schema from the $merge and $patch schemas
* @param {SchemaBuildOptions}
* @return {JsonSchema}
*/
async build (options = {}) {
if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
return this
}
if (this.isBuilding) {
return new Promise(resolve => this.buildHook.tap(() => resolve(this)))
}
this.isBuilding = true
const jsonschema = await App.instance.waitForModule('jsonschema')
const { applyExtensions, extensionFilter } = options
let built = _.cloneDeep(this.raw)
let parent = await this.getParent()
while (parent) {
const parentBuilt = _.cloneDeep((await parent.build(options)).built)
built = await this.patch(parentBuilt, built, { strict: !parent.name === BASE_SCHEMA_NAME })
parent = await parent.getParent()
}
if (this.extensions.length) {
await Promise.all(this.extensions.map(async s => {
const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
if (applyPatch) {
const extSchema = await jsonschema.getSchema(s)
this.patch(built, extSchema.raw, { extendAnnotations: false })
}
}))
}
this.built = built
this.compiled = await this.validator.compileAsync(built)
this.isBuilding = false
this.lastBuildTime = Date.now()
this.buildHook.invoke(this)
return this
}
/**
* Applies a patch schema to another schema
* @param {Object} baseSchema The base schema to apply the patch
* @param {Object} patchSchema The patch schema to apply to the base
* @param {ApplyPatchOptions} options
* @return {Object} The base schema
*/
async patch (baseSchema, patchSchema, options = {}) {
const opts = _.defaults(options, {
extendAnnotations: patchSchema.$anchor !== BASE_SCHEMA_NAME,
overwriteProperties: true,
strict: true
})
const patchData = patchSchema.$patch?.with ?? patchSchema.$merge?.with ?? (!opts.strict && patchSchema)
if (!patchData) {
throw App.instance.errors.INVALID_SCHEMA.setData({ schemaName: patchSchema.$anchor })
}
if (opts.extendAnnotations) {
['$anchor', 'title', 'description'].forEach(p => {
if (patchSchema[p]) baseSchema[p] = patchSchema[p]
})
}
if (patchData.properties) {
const mergeFunc = opts.overwriteProperties ? _.merge : _.defaultsDeep
mergeFunc(baseSchema.properties, patchData.properties)
}
['allOf', 'anyOf', 'oneOf'].forEach(p => {
if (patchData[p]?.length) baseSchema[p] = (baseSchema[p] ?? []).concat(_.cloneDeep(patchData[p]))
})
if (patchData.required) {
baseSchema.required = _.uniq([...(baseSchema.required ?? []), ...patchData.required])
}
return baseSchema
}
/**
* Checks passed data against the specified schema (if it exists)
* @param {Object} dataToValidate The data to be validated
* @param {SchemaValidateOptions} options
* @return {Promise} Resolves with the validated data
*/
async validate (dataToValidate, options) {
const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
const data = _.defaultsDeep(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
this.compiled(data)
const errors = this.compiled.errors && this.compiled.errors
.filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
.reduce((s, e) => `${s}${e}, `, '')
if (errors?.length) { throw App.instance.errors.VALIDATION_FAILED.setData({ schemaName: this.name, errors, data }) }
return data
}
/**
* Sanitises data by removing attributes according to the context (provided by options)
* @param {Object} dataToValidate The data to be sanitised
* @param {SchemaSanitiseOptions} options
* @return {Object} The sanitised data
*/
async sanitise (dataToSanitise, options = {}, schema) {
const opts = _.defaults(options, { isInternal: false, isReadOnly: false, sanitiseHtml: true, strict: true })
schema = schema ?? this.built
const sanitised = {}
for (const prop in schema.properties) {
const schemaData = schema.properties[prop]
const value = dataToSanitise[prop]
const ignore = (opts.isInternal && schemaData.isInternal) || (opts.isReadOnly && schemaData.isReadOnly)
if (value === undefined || (ignore && !opts.strict)) {
continue
}
if (ignore && opts.strict) {
throw App.instance.errors.MODIFY_PROTECTED_ATTR.setData({ attribute: prop, value })
}
sanitised[prop] =
schemaData.type === 'object' && schemaData.properties
? await this.sanitise(value, opts, schemaData)
: schemaData.type === 'string' && opts.sanitiseHtml
? this.xss.process(value)
: value
}
return sanitised
}
/**
* Adds an extension schema
* @param {String} extSchemaName
*/
addExtension (extSchemaName) {
!this.extensions.includes(extSchemaName) && this.extensions.push(extSchemaName)
}
/**
* Returns all schema defaults as a correctly structured object
* @param {Object} schema
* @param {Object} memo For recursion
* @returns {Object} The defaults object
*/
getObjectDefaults (schema) {
schema = schema ?? this.built
const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
return _.mapValues(props, s => s.type === 'object' && s.properties ? this.getObjectDefaults(s) : s.default)
}
}
export default JsonSchema