adapt-schemas 3.0.0 → 3.1.1
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 +14 -11
- package/lib/Schemas.js +20 -0
- package/package.json +1 -1
- package/test.js +161 -0
package/lib/Schema.js
CHANGED
|
@@ -272,10 +272,11 @@ class Schema extends EventEmitter {
|
|
|
272
272
|
* @param {Object} options
|
|
273
273
|
* @param {boolean} options.useDefaults Apply schema defaults (default: true)
|
|
274
274
|
* @param {boolean} options.ignoreRequired Ignore required field errors
|
|
275
|
+
* @param {boolean} options.ignoreErrors Ignore all validation errors (implies ignoreRequired)
|
|
275
276
|
* @returns {Object} The validated data
|
|
276
277
|
*/
|
|
277
278
|
validate (dataToValidate, options = {}) {
|
|
278
|
-
const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
|
|
279
|
+
const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false, ignoreErrors: false })
|
|
279
280
|
const data = _.cloneDeep(dataToValidate)
|
|
280
281
|
let compiled = opts.useDefaults ? this.compiledWithDefaults : this.compiled
|
|
281
282
|
|
|
@@ -287,17 +288,19 @@ class Schema extends EventEmitter {
|
|
|
287
288
|
|
|
288
289
|
compiled(data)
|
|
289
290
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
291
|
+
if (!opts.ignoreErrors) {
|
|
292
|
+
const errors = compiled.errors && compiled.errors
|
|
293
|
+
.filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
|
|
294
|
+
.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
|
|
295
|
+
.join(', ')
|
|
294
296
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
if (errors?.length) {
|
|
298
|
+
throw new SchemaError('VALIDATION_FAILED', `Validation failed for schema: ${this.name} with errors: ${errors}`, {
|
|
299
|
+
schemaName: this.name,
|
|
300
|
+
errors,
|
|
301
|
+
data
|
|
302
|
+
})
|
|
303
|
+
}
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
return data
|
package/lib/Schemas.js
CHANGED
|
@@ -110,6 +110,12 @@ class Schemas extends EventEmitter {
|
|
|
110
110
|
* Empties the schema registry (with the exception of the base schema)
|
|
111
111
|
*/
|
|
112
112
|
resetSchemaRegistry () {
|
|
113
|
+
// Remove previously registered schemas from AJV instances
|
|
114
|
+
for (const name of Object.keys(this.schemas)) {
|
|
115
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
116
|
+
if (v.getSchema(name)) v.removeSchema(name)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
113
119
|
this.schemas = {
|
|
114
120
|
base: this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
|
|
115
121
|
}
|
|
@@ -224,6 +230,16 @@ class Schemas extends EventEmitter {
|
|
|
224
230
|
}
|
|
225
231
|
|
|
226
232
|
this.schemas[schema.name] = schema
|
|
233
|
+
|
|
234
|
+
// AJV's sync compile() can only resolve $refs against schemas in its internal store to allow `$ref: "schemaName`"
|
|
235
|
+
// Note we only add simple schemas (no $merge/$patch)
|
|
236
|
+
if (!schema.raw.$merge && !schema.raw.$patch) {
|
|
237
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
238
|
+
if (v.getSchema(schema.name)) v.removeSchema(schema.name)
|
|
239
|
+
v.addSchema(schema.raw, schema.name)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
227
243
|
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
228
244
|
|
|
229
245
|
if (schema.raw.$patch) {
|
|
@@ -242,6 +258,10 @@ class Schemas extends EventEmitter {
|
|
|
242
258
|
if (this.schemas[name]) {
|
|
243
259
|
delete this.schemas[name]
|
|
244
260
|
}
|
|
261
|
+
// Remove from AJV instances
|
|
262
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
263
|
+
if (v.getSchema(name)) v.removeSchema(name)
|
|
264
|
+
}
|
|
245
265
|
// Remove schema from any extensions lists
|
|
246
266
|
Object.entries(this.schemaExtensions).forEach(([base, extensions]) => {
|
|
247
267
|
this.schemaExtensions[base] = extensions.filter(s => s !== name)
|
package/package.json
CHANGED
package/test.js
CHANGED
|
@@ -165,6 +165,63 @@ async function setupTestSchemas () {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// Create a config extension that mimics adapt-contrib-languagePicker
|
|
169
|
+
const languagePickerConfigSchema = {
|
|
170
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
171
|
+
$anchor: 'languagePicker-config',
|
|
172
|
+
$patch: {
|
|
173
|
+
source: { $ref: 'config' },
|
|
174
|
+
with: {
|
|
175
|
+
properties: {
|
|
176
|
+
_languagePicker: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
default: {},
|
|
179
|
+
properties: {
|
|
180
|
+
_isEnabled: { type: 'boolean', default: false },
|
|
181
|
+
_showOnCourseLoad: { type: 'boolean', default: true },
|
|
182
|
+
_languagePickerIconClass: { type: 'string', default: 'icon-language-2' },
|
|
183
|
+
_restoreStateOnLanguageChange: { type: 'boolean', default: false },
|
|
184
|
+
_classes: { type: 'string', default: '' },
|
|
185
|
+
_display: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
// deliberately no default: {} — tests Case 3
|
|
188
|
+
properties: {
|
|
189
|
+
_iconClass: { type: 'string', default: 'icon-default' },
|
|
190
|
+
_position: { type: 'string', default: 'left' }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
_languages: {
|
|
194
|
+
type: 'array',
|
|
195
|
+
items: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
_language: { type: 'string', default: '' },
|
|
199
|
+
_direction: { type: 'string', default: 'ltr' },
|
|
200
|
+
_isDisabled: { type: 'boolean', default: false },
|
|
201
|
+
displayName: { type: 'string', default: '' },
|
|
202
|
+
_buttons: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
default: {},
|
|
205
|
+
properties: {
|
|
206
|
+
yes: { type: 'string', default: 'Yes' },
|
|
207
|
+
no: { type: 'string', default: 'No' }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await fs.writeFile(
|
|
221
|
+
path.join(testSchemaDir, 'languagePicker-config.schema.json'),
|
|
222
|
+
JSON.stringify(languagePickerConfigSchema, null, 2)
|
|
223
|
+
)
|
|
224
|
+
|
|
168
225
|
// Create a patch schema that extends course
|
|
169
226
|
const coursePatchSchema = {
|
|
170
227
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
@@ -413,6 +470,110 @@ async function runTests () {
|
|
|
413
470
|
}
|
|
414
471
|
console.log('')
|
|
415
472
|
|
|
473
|
+
// Test 14: ignoreErrors applies defaults without throwing on invalid data
|
|
474
|
+
console.log('Test 14: ignoreErrors applies defaults without throwing')
|
|
475
|
+
const invalidData = await library.validate('course', {
|
|
476
|
+
title: 12345 // wrong type — would normally throw
|
|
477
|
+
}, { ignoreErrors: true })
|
|
478
|
+
const defaultApplied = invalidData.description === ''
|
|
479
|
+
const invalidPreserved = invalidData.title === 12345
|
|
480
|
+
console.log(` ✓ Default description applied despite type error: ${defaultApplied}`)
|
|
481
|
+
console.log(` ✓ Invalid title value preserved: ${invalidPreserved}`)
|
|
482
|
+
if (!defaultApplied || !invalidPreserved) {
|
|
483
|
+
throw new Error('ignoreErrors did not apply defaults or preserve existing data')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Verify it would throw without ignoreErrors
|
|
487
|
+
try {
|
|
488
|
+
await library.validate('course', { title: 12345 })
|
|
489
|
+
throw new Error('Should have thrown without ignoreErrors')
|
|
490
|
+
} catch (e) {
|
|
491
|
+
if (e.code !== 'VALIDATION_FAILED') throw e
|
|
492
|
+
console.log(' ✓ Same data throws without ignoreErrors: true')
|
|
493
|
+
}
|
|
494
|
+
console.log('')
|
|
495
|
+
|
|
496
|
+
// Test 15: ignoreErrors with missing required fields
|
|
497
|
+
console.log('Test 15: ignoreErrors ignores required field errors')
|
|
498
|
+
const missingRequired = await library.validate('course', {
|
|
499
|
+
description: 'No title provided'
|
|
500
|
+
}, { useDefaults: false, ignoreErrors: true })
|
|
501
|
+
const descPreserved = missingRequired.description === 'No title provided'
|
|
502
|
+
console.log(` ✓ Data returned despite missing required "title": ${descPreserved}`)
|
|
503
|
+
if (!descPreserved) {
|
|
504
|
+
throw new Error('ignoreErrors did not return data with missing required fields')
|
|
505
|
+
}
|
|
506
|
+
console.log('')
|
|
507
|
+
|
|
508
|
+
// Test 16: Issue #30 Case 1 — Partially populated object missing sibling defaults
|
|
509
|
+
console.log('Test 16: Issue #30 Case 1 — Partially populated plugin config')
|
|
510
|
+
const partialConfig = await library.validate('config', {
|
|
511
|
+
_languagePicker: {
|
|
512
|
+
_isEnabled: true,
|
|
513
|
+
_languages: [
|
|
514
|
+
{
|
|
515
|
+
_language: 'en',
|
|
516
|
+
_direction: 'ltr',
|
|
517
|
+
displayName: 'English'
|
|
518
|
+
}
|
|
519
|
+
]
|
|
520
|
+
}
|
|
521
|
+
}, { ignoreErrors: true })
|
|
522
|
+
const lp = partialConfig._languagePicker
|
|
523
|
+
const case1Checks = [
|
|
524
|
+
['_isEnabled preserved', lp._isEnabled === true],
|
|
525
|
+
['_showOnCourseLoad defaulted to true', lp._showOnCourseLoad === true],
|
|
526
|
+
['_languagePickerIconClass defaulted', lp._languagePickerIconClass === 'icon-language-2'],
|
|
527
|
+
['_restoreStateOnLanguageChange defaulted', lp._restoreStateOnLanguageChange === false],
|
|
528
|
+
['_classes defaulted to empty string', lp._classes === ''],
|
|
529
|
+
['_languages[0]._isDisabled defaulted', lp._languages[0]._isDisabled === false],
|
|
530
|
+
['_languages[0]._buttons defaulted', lp._languages[0]._buttons?.yes === 'Yes'],
|
|
531
|
+
['_languages[0]._buttons.no defaulted', lp._languages[0]._buttons?.no === 'No']
|
|
532
|
+
]
|
|
533
|
+
case1Checks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
|
|
534
|
+
const case1Failed = case1Checks.filter(([, ok]) => !ok)
|
|
535
|
+
if (case1Failed.length) {
|
|
536
|
+
throw new Error(`Case 1 failures: ${case1Failed.map(([d]) => d).join(', ')}`)
|
|
537
|
+
}
|
|
538
|
+
console.log('')
|
|
539
|
+
|
|
540
|
+
// Test 17: Issue #30 Case 2 — Plugin object entirely absent
|
|
541
|
+
console.log('Test 17: Issue #30 Case 2 — Plugin object entirely absent')
|
|
542
|
+
const emptyConfig = await library.validate('config', {}, { ignoreErrors: true })
|
|
543
|
+
const lp2 = emptyConfig._languagePicker
|
|
544
|
+
const case2Checks = [
|
|
545
|
+
['_languagePicker created from default: {}', lp2 !== undefined],
|
|
546
|
+
['_isEnabled defaulted to false', lp2?._isEnabled === false],
|
|
547
|
+
['_showOnCourseLoad defaulted to true', lp2?._showOnCourseLoad === true],
|
|
548
|
+
['_languagePickerIconClass defaulted', lp2?._languagePickerIconClass === 'icon-language-2']
|
|
549
|
+
]
|
|
550
|
+
case2Checks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
|
|
551
|
+
const case2Failed = case2Checks.filter(([, ok]) => !ok)
|
|
552
|
+
if (case2Failed.length) {
|
|
553
|
+
throw new Error(`Case 2 failures: ${case2Failed.map(([d]) => d).join(', ')}`)
|
|
554
|
+
}
|
|
555
|
+
console.log('')
|
|
556
|
+
|
|
557
|
+
// Test 18: Issue #30 Case 3 — Nested object without default: {}
|
|
558
|
+
console.log('Test 18: Issue #30 Case 3 — Nested object without default: {}')
|
|
559
|
+
const noNestedDefault = await library.validate('config', {
|
|
560
|
+
_languagePicker: { _isEnabled: true }
|
|
561
|
+
}, { ignoreErrors: true })
|
|
562
|
+
const lp3 = noNestedDefault._languagePicker
|
|
563
|
+
// _display has no default: {} so it should NOT be created by AJV
|
|
564
|
+
const displayMissing = lp3._display === undefined
|
|
565
|
+
console.log(` ✓ _display not created (no default: {}): ${displayMissing}`)
|
|
566
|
+
if (!displayMissing) {
|
|
567
|
+
console.log(` Note: _display was unexpectedly created: ${JSON.stringify(lp3._display)}`)
|
|
568
|
+
}
|
|
569
|
+
// But sibling defaults should still apply
|
|
570
|
+
const siblingsApplied = lp3._showOnCourseLoad === true && lp3._languagePickerIconClass === 'icon-language-2'
|
|
571
|
+
console.log(` ✓ Sibling defaults still applied: ${siblingsApplied}`)
|
|
572
|
+
if (!siblingsApplied) {
|
|
573
|
+
throw new Error('Sibling defaults not applied when nested object has no default')
|
|
574
|
+
}
|
|
575
|
+
console.log('')
|
|
576
|
+
|
|
416
577
|
console.log('=== All tests passed! ===')
|
|
417
578
|
} finally {
|
|
418
579
|
if (!hasSpecifiedPath) {
|