adapt-authoring-jsonschema 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/JsonSchema.js CHANGED
@@ -1,268 +1,268 @@
1
- import _ from 'lodash'
2
- import { App, Hook } from 'adapt-authoring-core'
3
- import fs from 'fs/promises'
4
- import xss from 'xss'
5
-
6
- /** @ignore */ const BASE_SCHEMA_NAME = 'base'
7
-
8
- /**
9
- * Functionality related to JSON schema
10
- * @memberof jsonschema
11
- */
12
- class JsonSchema {
13
- constructor ({ enableCache, filePath, validator, xssWhitelist }) {
14
- /**
15
- * The raw built JSON schema
16
- * @type {Object}
17
- */
18
- this.built = undefined
19
- /**
20
- * The compiled schema validation function
21
- * @type {function}
22
- */
23
- this.compiled = undefined
24
- /**
25
- * Whether caching is enabled for this schema
26
- * @type {Boolean}
27
- */
28
- this.enableCache = enableCache
29
- /**
30
- * List of extensions for this schema
31
- * @type {Array<String>}
32
- */
33
- this.extensions = []
34
- /**
35
- * File path to the schema
36
- * @type {String}
37
- */
38
- this.filePath = filePath
39
- /**
40
- * Whether the schema is currently building
41
- * @type {Boolean}
42
- */
43
- this.isBuilding = false
44
- /**
45
- * The last build time (in milliseconds)
46
- * @type {Number}
47
- */
48
- this.lastBuildTime = undefined
49
- /**
50
- * The raw schema data for this schema (with no inheritance/extensions)
51
- * @type {Object}
52
- */
53
- this.raw = undefined
54
- /**
55
- * Reference to the Ajv validator instance
56
- * @type {external:Ajv}
57
- */
58
- this.validator = validator
59
- /**
60
- * Reference to the local XSS sanitiser instance
61
- * @type {Object}
62
- */
63
- this.xss = new xss.FilterXSS({ whiteList: xssWhitelist })
64
- /**
65
- * Hook which invokes every time the schema is built
66
- * @type {Hook}
67
- */
68
- this.buildHook = new Hook()
69
- }
70
-
71
- /**
72
- * Determines whether the current schema build is valid using last modification timestamp
73
- * @returns {Boolean}
74
- */
75
- async isBuildValid () {
76
- if (!this.built) return false
77
- let schema = this
78
- while (schema) {
79
- const { mtimeMs } = await fs.stat(schema.filePath)
80
- if (mtimeMs > this.lastBuildTime) return false
81
- schema = await schema.getParent()
82
- }
83
- return true
84
- }
85
-
86
- /**
87
- * Returs the parent schema if $merge is defined (or the base schema if a root schema)
88
- * @returns {JsonSchema}
89
- */
90
- async getParent () {
91
- if (this.name === BASE_SCHEMA_NAME) return // base schema always the root
92
- const jsonschema = await App.instance.waitForModule('jsonschema')
93
- return await jsonschema.getSchema(this.raw?.$merge?.source?.$ref ?? BASE_SCHEMA_NAME)
94
- }
95
-
96
- /**
97
- * Loads the schema file
98
- * @returns {JsonSchema} This instance
99
- */
100
- async load () {
101
- try {
102
- this.raw = JSON.parse((await fs.readFile(this.filePath)).toString())
103
- this.name = this.raw.$anchor
104
- } catch (e) {
105
- throw App.instance.errors?.SCHEMA_LOAD_FAILED?.setData({ schemaName: this.filePath }) ?? e
106
- }
107
- if (this.validator.validateSchema(this.raw)?.errors) {
108
- const errors = this.validator.errors.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
109
- if (errors.length) {
110
- throw App.instance.errors.INVALID_SCHEMA
111
- .setData({ schemaName: this.name, errors: errors.join(', ') })
112
- }
113
- }
114
- return this
115
- }
116
-
117
- /**
118
- * Builds and compiles the schema from the $merge and $patch schemas
119
- * @param {LoadSchemaOptions}
120
- * @return {JsonSchema}
121
- */
122
- async build (options = {}) {
123
- if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
124
- return this
125
- }
126
- if (this.isBuilding) {
127
- return new Promise(resolve => this.buildHook.tap(() => resolve(this)))
128
- }
129
- this.isBuilding = true
130
-
131
- const jsonschema = await App.instance.waitForModule('jsonschema')
132
- const { applyExtensions, extensionFilter } = options
133
-
134
- let built = _.cloneDeep(this.raw)
135
- let parent = await this.getParent()
136
-
137
- while (parent) {
138
- const parentBuilt = _.cloneDeep((await parent.build(options)).built)
139
- built = await this.patch(parentBuilt, built, { strict: !parent.name === BASE_SCHEMA_NAME })
140
- parent = await parent.getParent()
141
- }
142
- if (this.extensions.length) {
143
- await Promise.all(this.extensions.map(async s => {
144
- const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
145
- if (applyPatch) {
146
- const extSchema = await jsonschema.getSchema(s)
147
- this.patch(built, extSchema.raw, { extendAnnotations: false })
148
- }
149
- }))
150
- }
151
- this.built = built
152
- this.compiled = await this.validator.compileAsync(built)
153
- this.isBuilding = false
154
- this.lastBuildTime = Date.now()
155
-
156
- this.buildHook.invoke(this)
157
- return this
158
- }
159
-
160
- /**
161
- * Applies a patch schema to another schema
162
- * @param {Object} baseSchema The base schema to apply the patch
163
- * @param {Object} patchSchema The patch schema to apply to the base
164
- * @param {ApplyPatchOptions} options
165
- * @return {Object} The base schema
166
- */
167
- patch (baseSchema, patchSchema, options = {}) {
168
- const opts = _.defaults(options, {
169
- extendAnnotations: patchSchema.$anchor !== BASE_SCHEMA_NAME,
170
- overwriteProperties: true,
171
- strict: true
172
- })
173
- const patchData = patchSchema.$patch?.with ?? patchSchema.$merge?.with ?? (!opts.strict && patchSchema)
174
- if (!patchData) {
175
- throw App.instance.errors.INVALID_SCHEMA.setData({ schemaName: patchSchema.$anchor })
176
- }
177
- if (opts.extendAnnotations) {
178
- ['$anchor', 'title', 'description'].forEach(p => {
179
- if (patchSchema[p]) baseSchema[p] = patchSchema[p]
180
- })
181
- }
182
- if (patchData.properties) {
183
- const mergeFunc = opts.overwriteProperties ? _.merge : _.defaultsDeep
184
- mergeFunc(baseSchema.properties, patchData.properties)
185
- }
186
- ['allOf', 'anyOf', 'oneOf'].forEach(p => {
187
- if (patchData[p]?.length) baseSchema[p] = (baseSchema[p] ?? []).concat(_.cloneDeep(patchData[p]))
188
- })
189
- if (patchData.required) {
190
- baseSchema.required = _.uniq([...(baseSchema.required ?? []), ...patchData.required])
191
- }
192
- return baseSchema
193
- }
194
-
195
- /**
196
- * Checks passed data against the specified schema (if it exists)
197
- * @param {Object} dataToValidate The data to be validated
198
- * @param {SchemaValidateOptions} options
199
- * @return {Object} The validated data
200
- */
201
- validate (dataToValidate, options) {
202
- const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
203
- const data = _.defaultsDeep(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
204
-
205
- this.compiled(data)
206
-
207
- const errors = this.compiled.errors && this.compiled.errors
208
- .filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
209
- .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
210
- .reduce((s, e) => `${s}${e}, `, '')
211
-
212
- if (errors?.length) { throw App.instance.errors.VALIDATION_FAILED.setData({ schemaName: this.name, errors, data }) }
213
-
214
- return data
215
- }
216
-
217
- /**
218
- * Sanitises data by removing attributes according to the context (provided by options)
219
- * @param {Object} dataToValidate The data to be sanitised
220
- * @param {SchemaSanitiseOptions} options
221
- * @return {Object} The sanitised data
222
- */
223
- sanitise (dataToSanitise, options = {}, schema) {
224
- const opts = _.defaults(options, { isInternal: false, isReadOnly: false, sanitiseHtml: true, strict: true })
225
- schema = schema ?? this.built
226
- const sanitised = {}
227
- for (const prop in schema.properties) {
228
- const schemaData = schema.properties[prop]
229
- const value = dataToSanitise[prop]
230
- const ignore = (opts.isInternal && schemaData.isInternal) || (opts.isReadOnly && schemaData.isReadOnly)
231
- if (value === undefined || (ignore && !opts.strict)) {
232
- continue
233
- }
234
- if (ignore && opts.strict) {
235
- throw App.instance.errors.MODIFY_PROTECTED_ATTR.setData({ attribute: prop, value })
236
- }
237
- sanitised[prop] =
238
- schemaData.type === 'object' && schemaData.properties
239
- ? this.sanitise(value, opts, schemaData)
240
- : schemaData.type === 'string' && opts.sanitiseHtml
241
- ? this.xss.process(value)
242
- : value
243
- }
244
- return sanitised
245
- }
246
-
247
- /**
248
- * Adds an extension schema
249
- * @param {String} extSchemaName
250
- */
251
- addExtension (extSchemaName) {
252
- !this.extensions.includes(extSchemaName) && this.extensions.push(extSchemaName)
253
- }
254
-
255
- /**
256
- * Returns all schema defaults as a correctly structured object
257
- * @param {Object} schema
258
- * @param {Object} memo For recursion
259
- * @returns {Object} The defaults object
260
- */
261
- getObjectDefaults (schema) {
262
- schema = schema ?? this.built
263
- const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
264
- return _.mapValues(props, s => s.type === 'object' && s.properties ? this.getObjectDefaults(s) : s.default)
265
- }
266
- }
267
-
268
- export default JsonSchema
1
+ import _ from 'lodash'
2
+ import { App, Hook } from 'adapt-authoring-core'
3
+ import fs from 'fs/promises'
4
+ import xss from 'xss'
5
+
6
+ /** @ignore */ const BASE_SCHEMA_NAME = 'base'
7
+
8
+ /**
9
+ * Functionality related to JSON schema
10
+ * @memberof jsonschema
11
+ */
12
+ class JsonSchema {
13
+ constructor ({ enableCache, filePath, validator, xssWhitelist }) {
14
+ /**
15
+ * The raw built JSON schema
16
+ * @type {Object}
17
+ */
18
+ this.built = undefined
19
+ /**
20
+ * The compiled schema validation function
21
+ * @type {function}
22
+ */
23
+ this.compiled = undefined
24
+ /**
25
+ * Whether caching is enabled for this schema
26
+ * @type {Boolean}
27
+ */
28
+ this.enableCache = enableCache
29
+ /**
30
+ * List of extensions for this schema
31
+ * @type {Array<String>}
32
+ */
33
+ this.extensions = []
34
+ /**
35
+ * File path to the schema
36
+ * @type {String}
37
+ */
38
+ this.filePath = filePath
39
+ /**
40
+ * Whether the schema is currently building
41
+ * @type {Boolean}
42
+ */
43
+ this.isBuilding = false
44
+ /**
45
+ * The last build time (in milliseconds)
46
+ * @type {Number}
47
+ */
48
+ this.lastBuildTime = undefined
49
+ /**
50
+ * The raw schema data for this schema (with no inheritance/extensions)
51
+ * @type {Object}
52
+ */
53
+ this.raw = undefined
54
+ /**
55
+ * Reference to the Ajv validator instance
56
+ * @type {external:Ajv}
57
+ */
58
+ this.validator = validator
59
+ /**
60
+ * Reference to the local XSS sanitiser instance
61
+ * @type {Object}
62
+ */
63
+ this.xss = new xss.FilterXSS({ whiteList: xssWhitelist })
64
+ /**
65
+ * Hook which invokes every time the schema is built
66
+ * @type {Hook}
67
+ */
68
+ this.buildHook = new Hook()
69
+ }
70
+
71
+ /**
72
+ * Determines whether the current schema build is valid using last modification timestamp
73
+ * @returns {Boolean}
74
+ */
75
+ async isBuildValid () {
76
+ if (!this.built) return false
77
+ let schema = this
78
+ while (schema) {
79
+ const { mtimeMs } = await fs.stat(schema.filePath)
80
+ if (mtimeMs > this.lastBuildTime) return false
81
+ schema = await schema.getParent()
82
+ }
83
+ return true
84
+ }
85
+
86
+ /**
87
+ * Returs the parent schema if $merge is defined (or the base schema if a root schema)
88
+ * @returns {JsonSchema}
89
+ */
90
+ async getParent () {
91
+ if (this.name === BASE_SCHEMA_NAME) return // base schema always the root
92
+ const jsonschema = await App.instance.waitForModule('jsonschema')
93
+ return await jsonschema.getSchema(this.raw?.$merge?.source?.$ref ?? BASE_SCHEMA_NAME)
94
+ }
95
+
96
+ /**
97
+ * Loads the schema file
98
+ * @returns {JsonSchema} This instance
99
+ */
100
+ async load () {
101
+ try {
102
+ this.raw = JSON.parse((await fs.readFile(this.filePath)).toString())
103
+ this.name = this.raw.$anchor
104
+ } catch (e) {
105
+ throw App.instance.errors?.SCHEMA_LOAD_FAILED?.setData({ schemaName: this.filePath }) ?? e
106
+ }
107
+ if (this.validator.validateSchema(this.raw)?.errors) {
108
+ const errors = this.validator.errors.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
109
+ if (errors.length) {
110
+ throw App.instance.errors.INVALID_SCHEMA
111
+ .setData({ schemaName: this.name, errors: errors.join(', ') })
112
+ }
113
+ }
114
+ return this
115
+ }
116
+
117
+ /**
118
+ * Builds and compiles the schema from the $merge and $patch schemas
119
+ * @param {LoadSchemaOptions}
120
+ * @return {JsonSchema}
121
+ */
122
+ async build (options = {}) {
123
+ if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
124
+ return this
125
+ }
126
+ if (this.isBuilding) {
127
+ return new Promise(resolve => this.buildHook.tap(() => resolve(this)))
128
+ }
129
+ this.isBuilding = true
130
+
131
+ const jsonschema = await App.instance.waitForModule('jsonschema')
132
+ const { applyExtensions, extensionFilter } = options
133
+
134
+ let built = _.cloneDeep(this.raw)
135
+ let parent = await this.getParent()
136
+
137
+ while (parent) {
138
+ const parentBuilt = _.cloneDeep((await parent.build(options)).built)
139
+ built = await this.patch(parentBuilt, built, { strict: !parent.name === BASE_SCHEMA_NAME })
140
+ parent = await parent.getParent()
141
+ }
142
+ if (this.extensions.length) {
143
+ await Promise.all(this.extensions.map(async s => {
144
+ const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
145
+ if (applyPatch) {
146
+ const extSchema = await jsonschema.getSchema(s)
147
+ this.patch(built, extSchema.raw, { extendAnnotations: false })
148
+ }
149
+ }))
150
+ }
151
+ this.built = built
152
+ this.compiled = await this.validator.compileAsync(built)
153
+ this.isBuilding = false
154
+ this.lastBuildTime = Date.now()
155
+
156
+ this.buildHook.invoke(this)
157
+ return this
158
+ }
159
+
160
+ /**
161
+ * Applies a patch schema to another schema
162
+ * @param {Object} baseSchema The base schema to apply the patch
163
+ * @param {Object} patchSchema The patch schema to apply to the base
164
+ * @param {ApplyPatchOptions} options
165
+ * @return {Object} The base schema
166
+ */
167
+ patch (baseSchema, patchSchema, options = {}) {
168
+ const opts = _.defaults(options, {
169
+ extendAnnotations: patchSchema.$anchor !== BASE_SCHEMA_NAME,
170
+ overwriteProperties: true,
171
+ strict: true
172
+ })
173
+ const patchData = patchSchema.$patch?.with ?? patchSchema.$merge?.with ?? (!opts.strict && patchSchema)
174
+ if (!patchData) {
175
+ throw App.instance.errors.INVALID_SCHEMA.setData({ schemaName: patchSchema.$anchor })
176
+ }
177
+ if (opts.extendAnnotations) {
178
+ ['$anchor', 'title', 'description'].forEach(p => {
179
+ if (patchSchema[p]) baseSchema[p] = patchSchema[p]
180
+ })
181
+ }
182
+ if (patchData.properties) {
183
+ const mergeFunc = opts.overwriteProperties ? _.merge : _.defaultsDeep
184
+ mergeFunc(baseSchema.properties, patchData.properties)
185
+ }
186
+ ['allOf', 'anyOf', 'oneOf'].forEach(p => {
187
+ if (patchData[p]?.length) baseSchema[p] = (baseSchema[p] ?? []).concat(_.cloneDeep(patchData[p]))
188
+ })
189
+ if (patchData.required) {
190
+ baseSchema.required = _.uniq([...(baseSchema.required ?? []), ...patchData.required])
191
+ }
192
+ return baseSchema
193
+ }
194
+
195
+ /**
196
+ * Checks passed data against the specified schema (if it exists)
197
+ * @param {Object} dataToValidate The data to be validated
198
+ * @param {SchemaValidateOptions} options
199
+ * @return {Object} The validated data
200
+ */
201
+ validate (dataToValidate, options) {
202
+ const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
203
+ const data = _.defaultsDeep(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
204
+
205
+ this.compiled(data)
206
+
207
+ const errors = this.compiled.errors && this.compiled.errors
208
+ .filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
209
+ .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
210
+ .reduce((s, e) => `${s}${e}, `, '')
211
+
212
+ if (errors?.length) { throw App.instance.errors.VALIDATION_FAILED.setData({ schemaName: this.name, errors, data }) }
213
+
214
+ return data
215
+ }
216
+
217
+ /**
218
+ * Sanitises data by removing attributes according to the context (provided by options)
219
+ * @param {Object} dataToValidate The data to be sanitised
220
+ * @param {SchemaSanitiseOptions} options
221
+ * @return {Object} The sanitised data
222
+ */
223
+ sanitise (dataToSanitise, options = {}, schema) {
224
+ const opts = _.defaults(options, { isInternal: false, isReadOnly: false, sanitiseHtml: true, strict: true })
225
+ schema = schema ?? this.built
226
+ const sanitised = {}
227
+ for (const prop in schema.properties) {
228
+ const schemaData = schema.properties[prop]
229
+ const value = dataToSanitise[prop]
230
+ const ignore = (opts.isInternal && schemaData.isInternal) || (opts.isReadOnly && schemaData.isReadOnly)
231
+ if (value === undefined || (ignore && !opts.strict)) {
232
+ continue
233
+ }
234
+ if (ignore && opts.strict) {
235
+ throw App.instance.errors.MODIFY_PROTECTED_ATTR.setData({ attribute: prop, value })
236
+ }
237
+ sanitised[prop] =
238
+ schemaData.type === 'object' && schemaData.properties
239
+ ? this.sanitise(value, opts, schemaData)
240
+ : schemaData.type === 'string' && opts.sanitiseHtml
241
+ ? this.xss.process(value)
242
+ : value
243
+ }
244
+ return sanitised
245
+ }
246
+
247
+ /**
248
+ * Adds an extension schema
249
+ * @param {String} extSchemaName
250
+ */
251
+ addExtension (extSchemaName) {
252
+ !this.extensions.includes(extSchemaName) && this.extensions.push(extSchemaName)
253
+ }
254
+
255
+ /**
256
+ * Returns all schema defaults as a correctly structured object
257
+ * @param {Object} schema
258
+ * @param {Object} memo For recursion
259
+ * @returns {Object} The defaults object
260
+ */
261
+ getObjectDefaults (schema) {
262
+ schema = schema ?? this.built
263
+ const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
264
+ return _.mapValues(props, s => s.type === 'object' && s.properties ? this.getObjectDefaults(s) : s.default)
265
+ }
266
+ }
267
+
268
+ export default JsonSchema