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/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