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/README.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# adapt-schemas
|
|
2
|
+
|
|
3
|
+
A standalone JSON Schema library for the Adapt framework stack. Load schemas from plugin folders via glob patterns, validate JSON data, and extract defaults including `_globals` from course schemas.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install adapt-schemas
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import Schemas from 'adapt-schemas'
|
|
15
|
+
|
|
16
|
+
// Create and initialize the library
|
|
17
|
+
const library = new Schemas()
|
|
18
|
+
await library.init()
|
|
19
|
+
|
|
20
|
+
// Load schemas from plugin directories
|
|
21
|
+
await library.loadSchemas('**/schema/*.schema.json', {
|
|
22
|
+
cwd: './plugins',
|
|
23
|
+
ignore: ['**/node_modules/**']
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Validate data against a schema
|
|
27
|
+
const validatedData = await library.validate('course', {
|
|
28
|
+
title: 'My Course'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Get _globals defaults from the course schema
|
|
32
|
+
const globals = await library.getGlobalsDefaults('course')
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API Reference
|
|
36
|
+
|
|
37
|
+
### Schemas
|
|
38
|
+
|
|
39
|
+
#### Constructor Options
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
const library = new Schemas({
|
|
43
|
+
enableCache: true, // Enable schema build caching (default: true)
|
|
44
|
+
xssWhitelist: {}, // Custom XSS whitelist tags/attributes
|
|
45
|
+
xssWhitelistOverride: false, // Replace defaults instead of extending
|
|
46
|
+
formatOverrides: {}, // Custom string format RegExp patterns
|
|
47
|
+
directoryReplacements: { // Replacements for isDirectory keyword
|
|
48
|
+
'$ROOT': '/app',
|
|
49
|
+
'$DATA': '/app/data'
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Methods
|
|
55
|
+
|
|
56
|
+
##### `init()`
|
|
57
|
+
Initializes the library and loads the base schema.
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
await library.init()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
##### `loadSchemas(patterns, options)`
|
|
64
|
+
Loads schemas from directories matching glob patterns.
|
|
65
|
+
|
|
66
|
+
```javascript
|
|
67
|
+
await library.loadSchemas('**/schema/*.schema.json', {
|
|
68
|
+
cwd: './plugins', // Base directory for patterns
|
|
69
|
+
ignore: ['**/excluded/**'] // Patterns to exclude
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Multiple patterns
|
|
73
|
+
await library.loadSchemas([
|
|
74
|
+
'core/**/schema/*.schema.json',
|
|
75
|
+
'plugins/**/schema/*.schema.json'
|
|
76
|
+
], { ignore: ['**/node_modules/**'] })
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
##### `registerSchema(filePath, options)`
|
|
80
|
+
Registers a single schema file.
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
await library.registerSchema('/path/to/schema.json', {
|
|
84
|
+
replace: false // Replace existing schema with same name
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
##### `getSchema(schemaName, options)`
|
|
89
|
+
Retrieves and builds a schema by name.
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
const schema = await library.getSchema('course', {
|
|
93
|
+
useCache: true, // Use cached build if available
|
|
94
|
+
compile: true, // Compile the schema
|
|
95
|
+
applyExtensions: true // Apply $patch extensions
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
##### `getBuiltSchema(schemaName)`
|
|
100
|
+
Returns the built schema object.
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
const schemaObj = await library.getBuiltSchema('course')
|
|
104
|
+
console.log(schemaObj.properties)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
##### `validate(schemaName, data, options)`
|
|
108
|
+
Validates data against a named schema.
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
const validated = await library.validate('course', inputData, {
|
|
112
|
+
useDefaults: true, // Apply schema defaults (default: true)
|
|
113
|
+
ignoreRequired: false // Ignore required field errors
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
##### `getSchemaDefaults(schemaName)`
|
|
118
|
+
Returns all defaults as a structured object.
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
const defaults = await library.getSchemaDefaults('course')
|
|
122
|
+
// { title: 'Untitled', _globals: { ... } }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
##### `getGlobalsDefaults(schemaName)`
|
|
126
|
+
Extracts `_globals` defaults from a schema.
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
const globals = await library.getGlobalsDefaults('course')
|
|
130
|
+
// { _accessibility: { _isEnabled: true, ... }, _extensions: { ... } }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
##### `getSchemaNames()`
|
|
134
|
+
Returns list of all registered schema names.
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
const names = library.getSchemaNames()
|
|
138
|
+
// ['base', 'course', 'content', 'component', ...]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
##### `getSchemaInfo()`
|
|
142
|
+
Returns information about all registered schemas.
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
const info = library.getSchemaInfo()
|
|
146
|
+
// { course: { filePath: '...', extensions: [...], isPatch: false } }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
##### `extendSchema(baseSchemaName, extSchemaName)`
|
|
150
|
+
Manually extends a schema with another.
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
library.extendSchema('course', 'my-course-extension')
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
##### `addKeyword(definition)`
|
|
157
|
+
Adds a custom AJV keyword.
|
|
158
|
+
|
|
159
|
+
```javascript
|
|
160
|
+
library.addKeyword({
|
|
161
|
+
keyword: 'isPositive',
|
|
162
|
+
type: 'number',
|
|
163
|
+
validate: (schema, data) => data > 0
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
##### `addStringFormats(formats)`
|
|
168
|
+
Adds custom string format validators.
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
library.addStringFormats({
|
|
172
|
+
'phone': /^\+?[\d\s-]+$/
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Events
|
|
177
|
+
|
|
178
|
+
The library extends EventEmitter and emits the following events:
|
|
179
|
+
|
|
180
|
+
```javascript
|
|
181
|
+
library.on('initialized', () => { })
|
|
182
|
+
library.on('reset', () => { })
|
|
183
|
+
library.on('schemasLoaded', (schemaNames) => { })
|
|
184
|
+
library.on('schemaRegistered', (name, filePath) => { })
|
|
185
|
+
library.on('schemaDeregistered', (name) => { })
|
|
186
|
+
library.on('schemaExtended', (baseName, extName) => { })
|
|
187
|
+
library.on('warning', (message) => { })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Schema Format
|
|
191
|
+
|
|
192
|
+
### Basic Schema with Inheritance
|
|
193
|
+
|
|
194
|
+
Schemas use `$merge` to inherit from a parent schema:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
199
|
+
"$anchor": "content",
|
|
200
|
+
"$merge": {
|
|
201
|
+
"source": { "$ref": "base" },
|
|
202
|
+
"with": {
|
|
203
|
+
"properties": {
|
|
204
|
+
"title": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"default": ""
|
|
207
|
+
},
|
|
208
|
+
"body": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"default": ""
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
"required": ["title"]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Patch Schema (Extensions)
|
|
220
|
+
|
|
221
|
+
Use `$patch` to extend an existing schema without creating a new one:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
226
|
+
"$anchor": "course-trickle-extension",
|
|
227
|
+
"$patch": {
|
|
228
|
+
"source": { "$ref": "course" },
|
|
229
|
+
"with": {
|
|
230
|
+
"properties": {
|
|
231
|
+
"_globals": {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"properties": {
|
|
234
|
+
"_trickle": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"properties": {
|
|
237
|
+
"incompleteContent": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"default": "There is incomplete content above"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Custom Keywords
|
|
252
|
+
|
|
253
|
+
The library includes these custom AJV keywords:
|
|
254
|
+
|
|
255
|
+
| Keyword | Description | Example |
|
|
256
|
+
|---------|-------------|---------|
|
|
257
|
+
| `isBytes` | Parses byte strings | `"1MB"` → `1048576` |
|
|
258
|
+
| `isDate` | Parses date strings | `"2024-01-01"` → `Date` |
|
|
259
|
+
| `isTimeMs` | Parses duration strings | `"7d"` → `604800000` |
|
|
260
|
+
| `isDirectory` | Resolves path tokens | `"$ROOT/data"` → `"/app/data"` |
|
|
261
|
+
| `isObjectId` | Marks ObjectId fields | No transformation |
|
|
262
|
+
|
|
263
|
+
## Error Handling
|
|
264
|
+
|
|
265
|
+
The library throws `SchemaError` with the following codes:
|
|
266
|
+
|
|
267
|
+
| Code | Description |
|
|
268
|
+
|------|-------------|
|
|
269
|
+
| `INVALID_PARAMS` | Invalid method parameters |
|
|
270
|
+
| `SCHEMA_EXISTS` | Schema with same name already registered |
|
|
271
|
+
| `SCHEMA_LOAD_FAILED` | Failed to read/parse schema file |
|
|
272
|
+
| `INVALID_SCHEMA` | Schema fails JSON Schema validation |
|
|
273
|
+
| `MISSING_SCHEMA` | Requested schema not found |
|
|
274
|
+
| `VALIDATION_FAILED` | Data fails schema validation |
|
|
275
|
+
| `MODIFY_PROTECTED_ATTR` | Attempt to modify internal/read-only field |
|
|
276
|
+
|
|
277
|
+
```javascript
|
|
278
|
+
import { SchemaError } from 'adapt-schemas'
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await library.validate('course', data)
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof SchemaError) {
|
|
284
|
+
console.log(e.code) // 'VALIDATION_FAILED'
|
|
285
|
+
console.log(e.data) // { schemaName, errors, data }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Schemas, { SchemaError } from './lib/Schemas.js'
|
|
2
|
+
import Schema from './lib/Schema.js'
|
|
3
|
+
import Keywords from './lib/Keywords.js'
|
|
4
|
+
import XSSDefaults from './lib/XSSDefaults.js'
|
|
5
|
+
|
|
6
|
+
export { Schemas, Schema, SchemaError, Keywords, XSSDefaults }
|
|
7
|
+
export default Schemas
|
package/lib/Keywords.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import bytes from 'bytes'
|
|
2
|
+
import ms from 'ms'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Custom JSON schema keywords for AJV
|
|
7
|
+
*/
|
|
8
|
+
class Keywords {
|
|
9
|
+
/**
|
|
10
|
+
* Returns all custom keywords
|
|
11
|
+
* @param {Object} directoryReplacements Replacements for isDirectory (e.g. { '$ROOT': '/app' })
|
|
12
|
+
* @returns {Object[]} Array of AJV keyword definitions
|
|
13
|
+
*/
|
|
14
|
+
static all(directoryReplacements = {}) {
|
|
15
|
+
const keywords = {
|
|
16
|
+
/**
|
|
17
|
+
* Parses byte string values (e.g., "1MB" -> 1048576)
|
|
18
|
+
*/
|
|
19
|
+
isBytes: function () {
|
|
20
|
+
return (value, { parentData, parentDataProperty }) => {
|
|
21
|
+
try {
|
|
22
|
+
parentData[parentDataProperty] = bytes.parse(value)
|
|
23
|
+
return true
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parses date string values into Date objects
|
|
32
|
+
*/
|
|
33
|
+
isDate: function () {
|
|
34
|
+
return (value, { parentData, parentDataProperty }) => {
|
|
35
|
+
try {
|
|
36
|
+
parentData[parentDataProperty] = new Date(value)
|
|
37
|
+
return true
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves directory path tokens ($ROOT, $DATA, $TEMP, etc.)
|
|
46
|
+
*/
|
|
47
|
+
isDirectory: function () {
|
|
48
|
+
const doReplace = value => {
|
|
49
|
+
const replacements = Object.entries(directoryReplacements)
|
|
50
|
+
return replacements.reduce((m, [k, v]) => {
|
|
51
|
+
return m.startsWith(k) ? path.resolve(v, m.replace(k, '').slice(1)) : m
|
|
52
|
+
}, value)
|
|
53
|
+
}
|
|
54
|
+
return (value, { parentData, parentDataProperty }) => {
|
|
55
|
+
try {
|
|
56
|
+
parentData[parentDataProperty] = doReplace(value)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Keep original value on error
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parses time duration strings into milliseconds (e.g., "7d" -> 604800000)
|
|
66
|
+
*/
|
|
67
|
+
isTimeMs: function () {
|
|
68
|
+
return (value, { parentData, parentDataProperty }) => {
|
|
69
|
+
try {
|
|
70
|
+
parentData[parentDataProperty] = ms(value)
|
|
71
|
+
return true
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Marker for ObjectId fields (no transformation, just marks the field)
|
|
80
|
+
*/
|
|
81
|
+
isObjectId: function () {
|
|
82
|
+
return () => true
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Object.entries(keywords).map(([keyword, compile]) => {
|
|
87
|
+
return {
|
|
88
|
+
keyword,
|
|
89
|
+
type: 'string',
|
|
90
|
+
modifying: true,
|
|
91
|
+
schemaType: 'boolean',
|
|
92
|
+
compile
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default Keywords
|