adapt-schemas 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/README.md +292 -0
- package/index.js +7 -0
- package/lib/Keywords.js +98 -0
- package/lib/Schema.js +389 -0
- package/lib/Schemas.js +370 -0
- package/lib/XSSDefaults.js +145 -0
- package/package.json +33 -0
- package/schema/base.schema.json +13 -0
- package/test.js +289 -0
package/lib/Schema.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import _ from 'lodash'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import fs from 'fs/promises'
|
|
4
|
+
import xss from 'xss'
|
|
5
|
+
|
|
6
|
+
const BASE_SCHEMA_NAME = 'base'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema-specific error class
|
|
10
|
+
*/
|
|
11
|
+
export class SchemaError extends Error {
|
|
12
|
+
constructor(code, message, data = {}) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.code = code
|
|
15
|
+
this.data = data
|
|
16
|
+
this.name = 'SchemaError'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Represents an individual JSON schema with validation and build capabilities
|
|
22
|
+
*/
|
|
23
|
+
class Schema extends EventEmitter {
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} options
|
|
26
|
+
* @param {boolean} options.enableCache Enable caching for this schema
|
|
27
|
+
* @param {string} options.filePath Path to the schema file
|
|
28
|
+
* @param {Ajv} options.validator Reference to the AJV validator
|
|
29
|
+
* @param {Object} options.xssWhitelist XSS whitelist configuration
|
|
30
|
+
* @param {SchemaLibrary} options.schemaLibrary Reference to the parent library
|
|
31
|
+
*/
|
|
32
|
+
constructor({ enableCache, filePath, validator, xssWhitelist, schemaLibrary }) {
|
|
33
|
+
super()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The raw built JSON schema
|
|
37
|
+
* @type {Object}
|
|
38
|
+
*/
|
|
39
|
+
this.built = undefined
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The compiled schema validation function
|
|
43
|
+
* @type {function}
|
|
44
|
+
*/
|
|
45
|
+
this.compiled = undefined
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether caching is enabled for this schema
|
|
49
|
+
* @type {boolean}
|
|
50
|
+
*/
|
|
51
|
+
this.enableCache = enableCache
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List of extensions for this schema
|
|
55
|
+
* @type {string[]}
|
|
56
|
+
*/
|
|
57
|
+
this.extensions = []
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* File path to the schema
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
this.filePath = filePath
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the schema is currently building
|
|
67
|
+
* @type {boolean}
|
|
68
|
+
*/
|
|
69
|
+
this.isBuilding = false
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The last build time (in milliseconds)
|
|
73
|
+
* @type {number}
|
|
74
|
+
*/
|
|
75
|
+
this.lastBuildTime = undefined
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The raw schema data for this schema (with no inheritance/extensions)
|
|
79
|
+
* @type {Object}
|
|
80
|
+
*/
|
|
81
|
+
this.raw = undefined
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The schema name (from $anchor)
|
|
85
|
+
* @type {string}
|
|
86
|
+
*/
|
|
87
|
+
this.name = undefined
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reference to the Ajv validator instance
|
|
91
|
+
* @type {Ajv}
|
|
92
|
+
*/
|
|
93
|
+
this.validator = validator
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reference to the local XSS sanitiser instance
|
|
97
|
+
* @type {Object}
|
|
98
|
+
*/
|
|
99
|
+
this.xss = new xss.FilterXSS({ whiteList: xssWhitelist })
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reference to the parent schema library
|
|
103
|
+
* @type {SchemaLibrary}
|
|
104
|
+
*/
|
|
105
|
+
this.schemaLibrary = schemaLibrary
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Callbacks waiting for build completion
|
|
109
|
+
* @type {function[]}
|
|
110
|
+
*/
|
|
111
|
+
this._buildCallbacks = []
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Determines whether the current schema build is valid using last modification timestamp
|
|
116
|
+
* @returns {Promise<boolean>}
|
|
117
|
+
*/
|
|
118
|
+
async isBuildValid() {
|
|
119
|
+
if (!this.built) return false
|
|
120
|
+
|
|
121
|
+
let schema = this
|
|
122
|
+
while (schema) {
|
|
123
|
+
const { mtimeMs } = await fs.stat(schema.filePath)
|
|
124
|
+
if (mtimeMs > this.lastBuildTime) return false
|
|
125
|
+
schema = await schema.getParent()
|
|
126
|
+
}
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Returns the parent schema if $merge is defined (or the base schema if a root schema)
|
|
132
|
+
* @returns {Promise<Schema|undefined>}
|
|
133
|
+
*/
|
|
134
|
+
async getParent() {
|
|
135
|
+
if (this.name === BASE_SCHEMA_NAME) return undefined
|
|
136
|
+
|
|
137
|
+
const parentRef = this.raw?.$merge?.source?.$ref ?? BASE_SCHEMA_NAME
|
|
138
|
+
return await this.schemaLibrary.getSchema(parentRef)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Loads the schema file
|
|
143
|
+
* @returns {Promise<Schema>} This instance
|
|
144
|
+
*/
|
|
145
|
+
async load() {
|
|
146
|
+
try {
|
|
147
|
+
const content = await fs.readFile(this.filePath, 'utf-8')
|
|
148
|
+
this.raw = JSON.parse(content)
|
|
149
|
+
this.name = this.raw.$anchor
|
|
150
|
+
} catch (e) {
|
|
151
|
+
throw new SchemaError('SCHEMA_LOAD_FAILED', `Failed to load schema: ${this.filePath}`, {
|
|
152
|
+
schemaName: this.filePath,
|
|
153
|
+
error: e.message
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.validator.validateSchema(this.raw)?.errors) {
|
|
158
|
+
const errors = this.validator.errors
|
|
159
|
+
.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
|
|
160
|
+
|
|
161
|
+
if (errors.length) {
|
|
162
|
+
throw new SchemaError('INVALID_SCHEMA', `Invalid schema: ${this.name}`, {
|
|
163
|
+
schemaName: this.name,
|
|
164
|
+
errors: errors.join(', ')
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Builds and compiles the schema from the $merge and $patch schemas
|
|
174
|
+
* @param {Object} options
|
|
175
|
+
* @param {boolean} options.useCache Use cached build if available
|
|
176
|
+
* @param {boolean} options.compile Compile the schema (default: true)
|
|
177
|
+
* @param {boolean} options.applyExtensions Apply extension schemas (default: true)
|
|
178
|
+
* @param {function} options.extensionFilter Filter function for extensions
|
|
179
|
+
* @returns {Promise<Schema>}
|
|
180
|
+
*/
|
|
181
|
+
async build(options = {}) {
|
|
182
|
+
if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
|
|
183
|
+
return this
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (this.isBuilding) {
|
|
187
|
+
return new Promise(resolve => this._buildCallbacks.push(() => resolve(this)))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.isBuilding = true
|
|
191
|
+
|
|
192
|
+
const { applyExtensions, extensionFilter } = options
|
|
193
|
+
|
|
194
|
+
let built = _.cloneDeep(this.raw)
|
|
195
|
+
let parent = await this.getParent()
|
|
196
|
+
|
|
197
|
+
while (parent) {
|
|
198
|
+
const parentBuilt = _.cloneDeep((await parent.build({ ...options, compile: false })).built)
|
|
199
|
+
built = this.patch(parentBuilt, built, { strict: !parent.name == BASE_SCHEMA_NAME })
|
|
200
|
+
parent = await parent.getParent()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (this.extensions.length) {
|
|
204
|
+
await Promise.all(this.extensions.map(async s => {
|
|
205
|
+
const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
|
|
206
|
+
if (applyPatch) {
|
|
207
|
+
const extSchema = await this.schemaLibrary.getSchema(s)
|
|
208
|
+
this.patch(built, extSchema.raw, { extendAnnotations: false })
|
|
209
|
+
}
|
|
210
|
+
}))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.built = built
|
|
214
|
+
|
|
215
|
+
if (options.compile !== false) {
|
|
216
|
+
this.compiled = await this.validator.compileAsync(built)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.isBuilding = false
|
|
220
|
+
this.lastBuildTime = Date.now()
|
|
221
|
+
|
|
222
|
+
// Notify waiting callbacks
|
|
223
|
+
this._buildCallbacks.forEach(cb => cb())
|
|
224
|
+
this._buildCallbacks = []
|
|
225
|
+
|
|
226
|
+
this.emit('built', this)
|
|
227
|
+
return this
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Applies a patch schema to another schema
|
|
232
|
+
* @param {Object} baseSchema The base schema to apply the patch
|
|
233
|
+
* @param {Object} patchSchema The patch schema to apply to the base
|
|
234
|
+
* @param {Object} options
|
|
235
|
+
* @param {boolean} options.extendAnnotations Extend $anchor, title, description
|
|
236
|
+
* @param {boolean} options.overwriteProperties Overwrite existing properties
|
|
237
|
+
* @param {boolean} options.strict Require $patch or $merge
|
|
238
|
+
* @returns {Object} The base schema
|
|
239
|
+
*/
|
|
240
|
+
patch(baseSchema, patchSchema, options = {}) {
|
|
241
|
+
const opts = _.defaults(options, {
|
|
242
|
+
extendAnnotations: patchSchema.$anchor !== BASE_SCHEMA_NAME,
|
|
243
|
+
overwriteProperties: true,
|
|
244
|
+
strict: true
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const patchData = patchSchema.$patch?.with ?? patchSchema.$merge?.with ?? (!opts.strict && patchSchema)
|
|
248
|
+
|
|
249
|
+
if (!patchData) {
|
|
250
|
+
throw new SchemaError('INVALID_SCHEMA', `Invalid patch schema: ${patchSchema.$anchor}`, {
|
|
251
|
+
schemaName: patchSchema.$anchor
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (opts.extendAnnotations) {
|
|
256
|
+
['$anchor', 'title', 'description'].forEach(p => {
|
|
257
|
+
if (patchSchema[p]) baseSchema[p] = patchSchema[p]
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (patchData.properties) {
|
|
262
|
+
const mergeFunc = opts.overwriteProperties ? _.merge : _.defaultsDeep
|
|
263
|
+
mergeFunc(baseSchema.properties, patchData.properties)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
['allOf', 'anyOf', 'oneOf'].forEach(p => {
|
|
267
|
+
if (patchData[p]?.length) {
|
|
268
|
+
baseSchema[p] = (baseSchema[p] ?? []).concat(_.cloneDeep(patchData[p]))
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
if (patchData.required) {
|
|
273
|
+
baseSchema.required = _.uniq([...(baseSchema.required ?? []), ...patchData.required])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return baseSchema
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validates data against the schema
|
|
281
|
+
* @param {Object} dataToValidate The data to be validated
|
|
282
|
+
* @param {Object} options
|
|
283
|
+
* @param {boolean} options.useDefaults Apply schema defaults (default: true)
|
|
284
|
+
* @param {boolean} options.ignoreRequired Ignore required field errors
|
|
285
|
+
* @returns {Object} The validated data
|
|
286
|
+
*/
|
|
287
|
+
validate(dataToValidate, options = {}) {
|
|
288
|
+
const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
|
|
289
|
+
const data = _.defaults(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
|
|
290
|
+
|
|
291
|
+
if (!this.compiled) {
|
|
292
|
+
this.emit('warning', `No compiled function for ${this.name}, compiling now`)
|
|
293
|
+
this.validator.compile(this.built)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.compiled(data)
|
|
297
|
+
|
|
298
|
+
const errors = this.compiled.errors && this.compiled.errors
|
|
299
|
+
.filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
|
|
300
|
+
.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
|
|
301
|
+
.reduce((s, e) => `${s}${e}, `, '')
|
|
302
|
+
|
|
303
|
+
if (errors?.length) {
|
|
304
|
+
throw new SchemaError('VALIDATION_FAILED', `Validation failed for schema: ${this.name}`, {
|
|
305
|
+
schemaName: this.name,
|
|
306
|
+
errors,
|
|
307
|
+
data
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return data
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sanitises data by removing attributes according to the context
|
|
316
|
+
* @param {Object} dataToSanitise The data to be sanitised
|
|
317
|
+
* @param {Object} options
|
|
318
|
+
* @param {boolean} options.isInternal Filter internal attributes
|
|
319
|
+
* @param {boolean} options.isReadOnly Filter read-only attributes
|
|
320
|
+
* @param {boolean} options.sanitiseHtml Apply XSS sanitization
|
|
321
|
+
* @param {boolean} options.strict Throw on protected attribute modification
|
|
322
|
+
* @param {Object} schema Schema to use (defaults to built schema)
|
|
323
|
+
* @returns {Object} The sanitised data
|
|
324
|
+
*/
|
|
325
|
+
sanitise(dataToSanitise, options = {}, schema) {
|
|
326
|
+
const opts = _.defaults(options, {
|
|
327
|
+
isInternal: false,
|
|
328
|
+
isReadOnly: false,
|
|
329
|
+
sanitiseHtml: true,
|
|
330
|
+
strict: true
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
schema = schema ?? this.built
|
|
334
|
+
const sanitised = {}
|
|
335
|
+
|
|
336
|
+
for (const prop in schema.properties) {
|
|
337
|
+
const schemaData = schema.properties[prop]
|
|
338
|
+
const value = dataToSanitise[prop]
|
|
339
|
+
const ignore = (opts.isInternal && schemaData.isInternal) || (opts.isReadOnly && schemaData.isReadOnly)
|
|
340
|
+
|
|
341
|
+
if (value === undefined || (ignore && !opts.strict)) {
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (ignore && opts.strict) {
|
|
346
|
+
throw new SchemaError('MODIFY_PROTECTED_ATTR', `Cannot modify protected attribute: ${prop}`, {
|
|
347
|
+
attribute: prop,
|
|
348
|
+
value
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
sanitised[prop] = schemaData.type === 'object' && schemaData.properties
|
|
353
|
+
? this.sanitise(value, opts, schemaData)
|
|
354
|
+
: schemaData.type === 'string' && opts.sanitiseHtml
|
|
355
|
+
? this.xss.process(value)
|
|
356
|
+
: value
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return sanitised
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Adds an extension schema
|
|
364
|
+
* @param {string} extSchemaName Extension schema name
|
|
365
|
+
*/
|
|
366
|
+
addExtension(extSchemaName) {
|
|
367
|
+
if (!this.extensions.includes(extSchemaName)) {
|
|
368
|
+
this.extensions.push(extSchemaName)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Returns all schema defaults as a correctly structured object
|
|
374
|
+
* @param {Object} schema Schema to extract defaults from
|
|
375
|
+
* @returns {Object} The defaults object
|
|
376
|
+
*/
|
|
377
|
+
getObjectDefaults(schema) {
|
|
378
|
+
schema = schema ?? this.built
|
|
379
|
+
const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
|
|
380
|
+
|
|
381
|
+
return _.mapValues(props, s => {
|
|
382
|
+
return s.type === 'object' && s.properties
|
|
383
|
+
? this.getObjectDefaults(s)
|
|
384
|
+
: s.default
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default Schema
|