hybrid-validator 0.5.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/LICENSE +21 -0
- package/README.md +213 -0
- package/lib/CodeGenerator.js +304 -0
- package/lib/NativeEngine.js +305 -0
- package/lib/Validator.js +44 -0
- package/lib/index.js +3 -0
- package/lib/package.json +3 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrii Kotsiuba and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
|
|
2
|
+
# JSON Schema Validator
|
|
3
|
+
|
|
4
|
+
High-performance JSON schema validator for Node.js.
|
|
5
|
+
|
|
6
|
+
Designed for:
|
|
7
|
+
|
|
8
|
+
- ⚡ maximum performance
|
|
9
|
+
- 🧠 smart compiled validators
|
|
10
|
+
- 🔧 extensibility
|
|
11
|
+
- 📦 minimal overhead
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Why another validator?
|
|
16
|
+
|
|
17
|
+
Most validators focus on flexibility but sacrifice performance.
|
|
18
|
+
|
|
19
|
+
This project focuses on **raw validation speed** while still supporting:
|
|
20
|
+
|
|
21
|
+
- runtime validation
|
|
22
|
+
- compiled validators
|
|
23
|
+
- custom types
|
|
24
|
+
- custom validators
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install json-schema-validator
|
|
32
|
+
````
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Quick Example
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { Validator } from "json-schema-validator";
|
|
40
|
+
|
|
41
|
+
const validator = new Validator();
|
|
42
|
+
|
|
43
|
+
const schema = {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
name: { type: "string" },
|
|
47
|
+
age: { type: "number" }
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const data = {
|
|
52
|
+
name: "John",
|
|
53
|
+
age: 30
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const result = validator.validate({
|
|
57
|
+
schema,
|
|
58
|
+
data
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
console.log(result);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# Two Validation Modes
|
|
67
|
+
|
|
68
|
+
## Runtime validation
|
|
69
|
+
|
|
70
|
+
Use when data is validated **only once**.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
validator.validate({ schema, data });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Advantages:
|
|
77
|
+
|
|
78
|
+
* no compilation cost
|
|
79
|
+
* fastest for single validation
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Compiled validation
|
|
84
|
+
|
|
85
|
+
Use when validating **large datasets**.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const validate = validator.compile(schema);
|
|
89
|
+
|
|
90
|
+
validate(data1);
|
|
91
|
+
validate(data2);
|
|
92
|
+
validate(data3);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Advantages:
|
|
96
|
+
|
|
97
|
+
* schema compiled once
|
|
98
|
+
* extremely fast repeated validation
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
# Performance
|
|
103
|
+
|
|
104
|
+
Benchmark executed using **tinybench**.
|
|
105
|
+
|
|
106
|
+
Libraries compared:
|
|
107
|
+
|
|
108
|
+
* Ajv
|
|
109
|
+
* Zod
|
|
110
|
+
* Yup
|
|
111
|
+
* Fastest Validator
|
|
112
|
+
|
|
113
|
+
Results:
|
|
114
|
+
|
|
115
|
+
| Validator | ops/sec | relative speed |
|
|
116
|
+
| ----------------- | ------------- | -------------- |
|
|
117
|
+
| **NativeEngine** | **1,186,514** | 🥇 fastest |
|
|
118
|
+
| CompiledValidator | 94,039 | 12× slower |
|
|
119
|
+
| Yup | 11,225 | 100× slower |
|
|
120
|
+
| Zod | 7,493 | 158× slower |
|
|
121
|
+
| FastestValidator | 3,796 | 312× slower |
|
|
122
|
+
| AJV (cold start) | 225 | 5270× slower |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
# Benchmark Chart
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
NativeEngine █████████████████████████████████████████ 1,186,514
|
|
131
|
+
CompiledValidator ████ 94,039
|
|
132
|
+
Yup █ 11,225
|
|
133
|
+
Zod █ 7,493
|
|
134
|
+
FastestValidator ▌ 3,796
|
|
135
|
+
AJV cold ▏ 225
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
# When to use which mode
|
|
142
|
+
|
|
143
|
+
| Scenario | Method |
|
|
144
|
+
| -------------------------- | ------------- |
|
|
145
|
+
| single validation | `.validate()` |
|
|
146
|
+
| large dataset | `.compile()` |
|
|
147
|
+
| high performance pipelines | `.compile()` |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
# Cache
|
|
152
|
+
|
|
153
|
+
Compiled schemas are automatically cached using `WeakMap`.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
const validator = new Validator();
|
|
157
|
+
|
|
158
|
+
const validate = validator.compile(schema);
|
|
159
|
+
|
|
160
|
+
// reused from cache
|
|
161
|
+
const validate2 = validator.compile(schema);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Clear cache:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
validator.clearCache();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
# Options
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const validator = new Validator({
|
|
176
|
+
recursive: true,
|
|
177
|
+
strict: true,
|
|
178
|
+
customTypes: {},
|
|
179
|
+
customValidators: []
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
| option | description |
|
|
184
|
+
| ---------------- | -------------------------- |
|
|
185
|
+
| recursive | validate nested structures |
|
|
186
|
+
| strict | strict schema validation |
|
|
187
|
+
| customTypes | user defined types |
|
|
188
|
+
| customValidators | custom validators |
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
# Architecture
|
|
193
|
+
|
|
194
|
+
Validator uses two internal engines:
|
|
195
|
+
|
|
196
|
+
| Engine | Description |
|
|
197
|
+
| ------------- | ------------------ |
|
|
198
|
+
| NativeEngine | runtime validation |
|
|
199
|
+
| CodeGenerator | compiled validator |
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
# Roadmap
|
|
204
|
+
|
|
205
|
+
* more JSON Schema features
|
|
206
|
+
* async validation
|
|
207
|
+
* schema precompilation
|
|
208
|
+
* browser support
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
# License
|
|
213
|
+
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
export class CodeGenerator {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.options = {
|
|
4
|
+
recursive: true,
|
|
5
|
+
strict: true,
|
|
6
|
+
customTypes: {},
|
|
7
|
+
customValidators: [],
|
|
8
|
+
...options,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
compile(schema) {
|
|
13
|
+
const { customTypes, customValidators, recursive } = this.options;
|
|
14
|
+
|
|
15
|
+
const code = [];
|
|
16
|
+
code.push(`const errors = [];`);
|
|
17
|
+
|
|
18
|
+
const gen = (schema, dataRef, pathExpr, depth = 0) => {
|
|
19
|
+
if (schema === true) return;
|
|
20
|
+
|
|
21
|
+
if (schema === false) {
|
|
22
|
+
code.push(`
|
|
23
|
+
errors.push({
|
|
24
|
+
path: ${pathExpr},
|
|
25
|
+
message: "Schema is false, value is not allowed"
|
|
26
|
+
});
|
|
27
|
+
`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!schema) return;
|
|
32
|
+
|
|
33
|
+
if (schema.type) {
|
|
34
|
+
const typeCheck = this.generateTypeCheck(schema.type, dataRef);
|
|
35
|
+
const typeLabel = Array.isArray(schema.type)
|
|
36
|
+
? schema.type.join(', ')
|
|
37
|
+
: schema.type;
|
|
38
|
+
|
|
39
|
+
code.push(`
|
|
40
|
+
if (${typeCheck}) {
|
|
41
|
+
`);
|
|
42
|
+
|
|
43
|
+
if (schema.enum) {
|
|
44
|
+
const values = JSON.stringify(schema.enum);
|
|
45
|
+
code.push(`
|
|
46
|
+
if (!${values}.includes(${dataRef})) {
|
|
47
|
+
errors.push({
|
|
48
|
+
path: ${pathExpr},
|
|
49
|
+
message: "Value '" + ${dataRef} + "' is not one of: ${schema.enum.join(', ')}"
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (schema.minimum !== undefined) {
|
|
56
|
+
code.push(`
|
|
57
|
+
if (${dataRef} < ${schema.minimum}) {
|
|
58
|
+
errors.push({
|
|
59
|
+
path: ${pathExpr},
|
|
60
|
+
message: "Value " + ${dataRef} + " < minimum ${schema.minimum}"
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (schema.maximum !== undefined) {
|
|
67
|
+
code.push(`
|
|
68
|
+
if (${dataRef} > ${schema.maximum}) {
|
|
69
|
+
errors.push({
|
|
70
|
+
path: ${pathExpr},
|
|
71
|
+
message: "Value " + ${dataRef} + " > maximum ${schema.maximum}"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (schema.exclusiveMinimum !== undefined) {
|
|
78
|
+
code.push(`
|
|
79
|
+
if (${dataRef} <= ${schema.exclusiveMinimum}) {
|
|
80
|
+
errors.push({
|
|
81
|
+
path: ${pathExpr},
|
|
82
|
+
message: "Value " + ${dataRef} + " <= exclusiveMinimum ${schema.exclusiveMinimum}"
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (schema.exclusiveMaximum !== undefined) {
|
|
89
|
+
code.push(`
|
|
90
|
+
if (${dataRef} >= ${schema.exclusiveMaximum}) {
|
|
91
|
+
errors.push({
|
|
92
|
+
path: ${pathExpr},
|
|
93
|
+
message: "Value " + ${dataRef} + " >= exclusiveMaximum ${schema.exclusiveMaximum}"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (schema.multipleOf !== undefined) {
|
|
100
|
+
code.push(`
|
|
101
|
+
if (${dataRef} % ${schema.multipleOf} !== 0) {
|
|
102
|
+
errors.push({
|
|
103
|
+
path: ${pathExpr},
|
|
104
|
+
message: "Value " + ${dataRef} + " not multipleOf ${schema.multipleOf}"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (recursive && schema.type === 'object') {
|
|
111
|
+
code.push(`
|
|
112
|
+
if (${dataRef} && typeof ${dataRef} === 'object' && !Array.isArray(${dataRef})) {
|
|
113
|
+
`);
|
|
114
|
+
if (schema.properties) {
|
|
115
|
+
for (const key in schema.properties) {
|
|
116
|
+
const prop = schema.properties[key];
|
|
117
|
+
const ref = `${dataRef}.${key}`;
|
|
118
|
+
|
|
119
|
+
const childPath =
|
|
120
|
+
pathExpr === '""' ? `"${key}"` : `${pathExpr} + "." + "${key}"`;
|
|
121
|
+
|
|
122
|
+
code.push(`
|
|
123
|
+
if (${dataRef}.${key} !== undefined) {
|
|
124
|
+
`);
|
|
125
|
+
gen(prop, ref, childPath, depth);
|
|
126
|
+
code.push(`}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (schema.additionalProperties === false && schema.properties) {
|
|
131
|
+
const keys = JSON.stringify(Object.keys(schema.properties));
|
|
132
|
+
|
|
133
|
+
code.push(`
|
|
134
|
+
for (const k in ${dataRef}) {
|
|
135
|
+
if (!${keys}.includes(k)) {
|
|
136
|
+
errors.push({
|
|
137
|
+
path: k,
|
|
138
|
+
message: "Unexpected field '" + k + "'"
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof schema.additionalProperties === 'object') {
|
|
146
|
+
code.push(`
|
|
147
|
+
for (const k in ${dataRef}) {
|
|
148
|
+
`);
|
|
149
|
+
if (schema.properties) {
|
|
150
|
+
const keys = JSON.stringify(Object.keys(schema.properties));
|
|
151
|
+
|
|
152
|
+
code.push(`
|
|
153
|
+
if (!${keys}.includes(k)) {
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
gen(
|
|
157
|
+
schema.additionalProperties,
|
|
158
|
+
`${dataRef}[k]`,
|
|
159
|
+
pathExpr === '""' ? `k` : `${pathExpr} + "." + k`,
|
|
160
|
+
depth,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
code.push(`}`);
|
|
164
|
+
} else {
|
|
165
|
+
gen(
|
|
166
|
+
schema.additionalProperties,
|
|
167
|
+
`${dataRef}[k]`,
|
|
168
|
+
pathExpr === '""' ? `k` : `${pathExpr} + "." + k`,
|
|
169
|
+
depth,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
code.push(`}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (schema.patternProperties) {
|
|
177
|
+
for (const pattern in schema.patternProperties) {
|
|
178
|
+
const propSchema = schema.patternProperties[pattern];
|
|
179
|
+
const regexName = `regex_${pattern.replace(/\W/g, '_')}`;
|
|
180
|
+
|
|
181
|
+
code.push(`
|
|
182
|
+
const ${regexName} = new RegExp(${JSON.stringify(pattern)});
|
|
183
|
+
for (const k in ${dataRef}) {
|
|
184
|
+
if (${regexName}.test(k)) {
|
|
185
|
+
`);
|
|
186
|
+
|
|
187
|
+
gen(
|
|
188
|
+
propSchema,
|
|
189
|
+
`${dataRef}[k]`,
|
|
190
|
+
pathExpr === '""' ? `k` : `${pathExpr} + "." + k`,
|
|
191
|
+
depth,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
code.push(`
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
code.push(`
|
|
202
|
+
} // end if object
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (recursive && schema.type === 'array' && schema.items) {
|
|
207
|
+
const loopVar = `i${depth}`;
|
|
208
|
+
code.push(`
|
|
209
|
+
if (Array.isArray(${dataRef})) {
|
|
210
|
+
for (let ${loopVar} = 0; ${loopVar} < ${dataRef}.length; ${loopVar}++) {
|
|
211
|
+
`);
|
|
212
|
+
|
|
213
|
+
gen(
|
|
214
|
+
schema.items,
|
|
215
|
+
`${dataRef}[${loopVar}]`,
|
|
216
|
+
pathExpr === '""'
|
|
217
|
+
? `"[" + ${loopVar} + "]"`
|
|
218
|
+
: `${pathExpr} + "[" + ${loopVar} + "]"`,
|
|
219
|
+
depth + 1,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
code.push(`
|
|
223
|
+
}
|
|
224
|
+
} // end if array
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
code.push(`
|
|
229
|
+
} else {
|
|
230
|
+
const actual = Array.isArray(${dataRef}) ? "array" : typeof ${dataRef};
|
|
231
|
+
errors.push({
|
|
232
|
+
path: ${pathExpr},
|
|
233
|
+
message: "Expected type '${typeLabel}', got '" + actual + "'"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
`);
|
|
237
|
+
} else if (schema.enum) {
|
|
238
|
+
const values = JSON.stringify(schema.enum);
|
|
239
|
+
code.push(`
|
|
240
|
+
if (!${values}.includes(${dataRef})) {
|
|
241
|
+
errors.push({
|
|
242
|
+
path: ${pathExpr},
|
|
243
|
+
message: "Value '" + ${dataRef} + "' is not one of: ${schema.enum.join(', ')}"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
gen(schema, 'data', `""`, 0);
|
|
251
|
+
|
|
252
|
+
code.push(`
|
|
253
|
+
for (let i = 0; i < customValidators.length; i++) {
|
|
254
|
+
customValidators[i](data, "", errors);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
isValid: errors.length === 0,
|
|
259
|
+
errors
|
|
260
|
+
};
|
|
261
|
+
`);
|
|
262
|
+
|
|
263
|
+
const fn = new Function(
|
|
264
|
+
'data',
|
|
265
|
+
'customTypes',
|
|
266
|
+
'customValidators',
|
|
267
|
+
code.join('\n'),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return (data) => fn(data, customTypes, customValidators);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
generateTypeCheck(type, ref) {
|
|
274
|
+
if (Array.isArray(type)) {
|
|
275
|
+
return type.map((t) => this.generateTypeCheck(t, ref)).join(' || ');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
switch (type) {
|
|
279
|
+
case 'string':
|
|
280
|
+
return `typeof ${ref} === "string"`;
|
|
281
|
+
|
|
282
|
+
case 'number':
|
|
283
|
+
return `typeof ${ref} === "number" && Number.isFinite(${ref})`;
|
|
284
|
+
|
|
285
|
+
case 'integer':
|
|
286
|
+
return `Number.isInteger(${ref})`;
|
|
287
|
+
|
|
288
|
+
case 'boolean':
|
|
289
|
+
return `typeof ${ref} === "boolean"`;
|
|
290
|
+
|
|
291
|
+
case 'array':
|
|
292
|
+
return `Array.isArray(${ref})`;
|
|
293
|
+
|
|
294
|
+
case 'object':
|
|
295
|
+
return `${ref} !== null && typeof ${ref} === "object" && !Array.isArray(${ref})`;
|
|
296
|
+
|
|
297
|
+
case 'null':
|
|
298
|
+
return `${ref} === null`;
|
|
299
|
+
|
|
300
|
+
default:
|
|
301
|
+
return `customTypes["${type}"] ? customTypes["${type}"](${ref}) : typeof ${ref} === "${type}"`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
export class NativeEngine {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
const {
|
|
4
|
+
recursive = true,
|
|
5
|
+
strict = false,
|
|
6
|
+
customValidators = [],
|
|
7
|
+
customTypes = {},
|
|
8
|
+
} = options;
|
|
9
|
+
|
|
10
|
+
this.options = { recursive, strict };
|
|
11
|
+
this.customValidators = customValidators;
|
|
12
|
+
this.customTypes = customTypes;
|
|
13
|
+
this.errors = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ==================================================
|
|
17
|
+
// PUBLIC
|
|
18
|
+
// ==================================================
|
|
19
|
+
|
|
20
|
+
validate({ schema, data }) {
|
|
21
|
+
this.errors = [];
|
|
22
|
+
|
|
23
|
+
this.#validateBySchema(schema, data, '');
|
|
24
|
+
|
|
25
|
+
for (const validator of this.customValidators) {
|
|
26
|
+
validator(data, '', this.errors);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isValid: this.errors.length === 0,
|
|
31
|
+
errors: this.errors,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ==================================================
|
|
36
|
+
// TYPE CHECKING
|
|
37
|
+
// ==================================================
|
|
38
|
+
|
|
39
|
+
#checkType(value, expectedType) {
|
|
40
|
+
if (Array.isArray(expectedType)) {
|
|
41
|
+
return expectedType.some((t) => this.#checkType(value, t));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (this.customTypes[expectedType]) {
|
|
45
|
+
return this.customTypes[expectedType](value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
switch (expectedType) {
|
|
49
|
+
case 'integer':
|
|
50
|
+
return Number.isInteger(value);
|
|
51
|
+
|
|
52
|
+
case 'number':
|
|
53
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
54
|
+
|
|
55
|
+
case 'string':
|
|
56
|
+
return typeof value === 'string';
|
|
57
|
+
|
|
58
|
+
case 'boolean':
|
|
59
|
+
return typeof value === 'boolean';
|
|
60
|
+
|
|
61
|
+
case 'array':
|
|
62
|
+
return Array.isArray(value);
|
|
63
|
+
|
|
64
|
+
case 'object':
|
|
65
|
+
return (
|
|
66
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
case 'null':
|
|
70
|
+
return value === null;
|
|
71
|
+
|
|
72
|
+
default:
|
|
73
|
+
return typeof value === expectedType;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#typeError(path, expected, value) {
|
|
78
|
+
const actual = Array.isArray(value) ? 'array' : typeof value;
|
|
79
|
+
|
|
80
|
+
const expectedStr = Array.isArray(expected)
|
|
81
|
+
? expected.join(', ')
|
|
82
|
+
: expected;
|
|
83
|
+
|
|
84
|
+
this.errors.push({
|
|
85
|
+
path: path || 'root',
|
|
86
|
+
message: `Expected type '${expectedStr}', got '${actual}'`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ==================================================
|
|
91
|
+
// COMMON RULES
|
|
92
|
+
// ==================================================
|
|
93
|
+
|
|
94
|
+
#validateEnum(value, schema, path) {
|
|
95
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
96
|
+
this.errors.push({
|
|
97
|
+
path,
|
|
98
|
+
message: `Value '${value}' is not one of: ${schema.enum.join(', ')}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#validateFormat(value, schema, path) {
|
|
104
|
+
if (!schema.format) return;
|
|
105
|
+
|
|
106
|
+
if (schema.format === 'date-time') {
|
|
107
|
+
const date = new Date(value);
|
|
108
|
+
|
|
109
|
+
if (Number.isNaN(date.getTime())) {
|
|
110
|
+
this.errors.push({
|
|
111
|
+
path,
|
|
112
|
+
message: `Value '${value}' is not a valid ISO 8601 date-time`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#validateNumberRules(value, schema, path) {
|
|
119
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
|
120
|
+
|
|
121
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
122
|
+
this.errors.push({
|
|
123
|
+
path,
|
|
124
|
+
message: `Value ${value} is less than minimum ${schema.minimum}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
schema.exclusiveMinimum !== undefined &&
|
|
130
|
+
value <= schema.exclusiveMinimum
|
|
131
|
+
) {
|
|
132
|
+
this.errors.push({
|
|
133
|
+
path,
|
|
134
|
+
message: `Value ${value} must be greater than ${schema.exclusiveMinimum}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
139
|
+
this.errors.push({
|
|
140
|
+
path,
|
|
141
|
+
message: `Value ${value} is greater than maximum ${schema.maximum}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
schema.exclusiveMaximum !== undefined &&
|
|
147
|
+
value >= schema.exclusiveMaximum
|
|
148
|
+
) {
|
|
149
|
+
this.errors.push({
|
|
150
|
+
path,
|
|
151
|
+
message: `Value ${value} must be less than ${schema.exclusiveMaximum}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (schema.multipleOf !== undefined) {
|
|
156
|
+
if (value % schema.multipleOf !== 0) {
|
|
157
|
+
this.errors.push({
|
|
158
|
+
path,
|
|
159
|
+
message: `Value ${value} is not a multiple of ${schema.multipleOf}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#applyCommonRules(value, schema, path) {
|
|
166
|
+
this.#validateEnum(value, schema, path);
|
|
167
|
+
this.#validateFormat(value, schema, path);
|
|
168
|
+
|
|
169
|
+
if (schema.type === 'number' || schema.type === 'integer') {
|
|
170
|
+
this.#validateNumberRules(value, schema, path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ==================================================
|
|
175
|
+
// OBJECT
|
|
176
|
+
// ==================================================
|
|
177
|
+
|
|
178
|
+
#validateObject(schema, data, path = '') {
|
|
179
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return;
|
|
180
|
+
|
|
181
|
+
const { strict } = this.options;
|
|
182
|
+
const validatedKeys = new Set();
|
|
183
|
+
|
|
184
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
185
|
+
for (const field of schema.required) {
|
|
186
|
+
if (!(field in data)) {
|
|
187
|
+
this.errors.push({
|
|
188
|
+
path: path ? `${path}.${field}` : field,
|
|
189
|
+
message: `Required field '${field}' is missing`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (schema.properties) {
|
|
196
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
197
|
+
validatedKeys.add(key);
|
|
198
|
+
|
|
199
|
+
if (key in data) {
|
|
200
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
201
|
+
|
|
202
|
+
this.#validateBySchema(propSchema, data[key], currentPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (schema.patternProperties) {
|
|
208
|
+
for (const [pattern, propSchema] of Object.entries(
|
|
209
|
+
schema.patternProperties,
|
|
210
|
+
)) {
|
|
211
|
+
const regex = new RegExp(pattern);
|
|
212
|
+
|
|
213
|
+
for (const key of Object.keys(data)) {
|
|
214
|
+
if (regex.test(key)) {
|
|
215
|
+
validatedKeys.add(key);
|
|
216
|
+
|
|
217
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
218
|
+
|
|
219
|
+
this.#validateBySchema(propSchema, data[key], currentPath);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const key of Object.keys(data)) {
|
|
226
|
+
if (validatedKeys.has(key)) continue;
|
|
227
|
+
|
|
228
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
229
|
+
|
|
230
|
+
if (schema.additionalProperties === false) {
|
|
231
|
+
this.errors.push({
|
|
232
|
+
path: currentPath,
|
|
233
|
+
message: `Unexpected field '${key}'`,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof schema.additionalProperties === 'object') {
|
|
240
|
+
this.#validateBySchema(
|
|
241
|
+
schema.additionalProperties,
|
|
242
|
+
data[key],
|
|
243
|
+
currentPath,
|
|
244
|
+
);
|
|
245
|
+
} else if (strict && !schema.additionalProperties) {
|
|
246
|
+
this.errors.push({
|
|
247
|
+
path: currentPath,
|
|
248
|
+
message: `Unexpected field '${key}'`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ==================================================
|
|
255
|
+
// ARRAY
|
|
256
|
+
// ==================================================
|
|
257
|
+
|
|
258
|
+
#validateArray(schema, data, path) {
|
|
259
|
+
if (!Array.isArray(data)) {
|
|
260
|
+
this.#typeError(path, 'array', data);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!schema.items) return;
|
|
265
|
+
|
|
266
|
+
data.forEach((item, index) => {
|
|
267
|
+
const itemPath = `${path}[${index}]`;
|
|
268
|
+
|
|
269
|
+
this.#validateBySchema(schema.items, item, itemPath);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ==================================================
|
|
274
|
+
// UNIVERSAL VALIDATOR
|
|
275
|
+
// ==================================================
|
|
276
|
+
|
|
277
|
+
#validateBySchema(schema, value, path) {
|
|
278
|
+
if (schema === true) return;
|
|
279
|
+
if (schema === false) {
|
|
280
|
+
this.errors.push({
|
|
281
|
+
path,
|
|
282
|
+
message: 'Schema is false, value is not allowed',
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!schema) return;
|
|
287
|
+
|
|
288
|
+
if (schema.type && !this.#checkType(value, schema.type)) {
|
|
289
|
+
this.#typeError(path, schema.type, value);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.#applyCommonRules(value, schema, path);
|
|
294
|
+
|
|
295
|
+
if (!this.options.recursive) return;
|
|
296
|
+
|
|
297
|
+
if (schema.type === 'object') {
|
|
298
|
+
this.#validateObject(schema, value, path);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (schema.type === 'array') {
|
|
302
|
+
this.#validateArray(schema, value, path);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
package/lib/Validator.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NativeEngine } from './NativeEngine.js';
|
|
2
|
+
import { CodeGenerator } from './CodeGenerator.js';
|
|
3
|
+
|
|
4
|
+
export class Validator {
|
|
5
|
+
#compiledCache;
|
|
6
|
+
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.options = {
|
|
9
|
+
recursive: true,
|
|
10
|
+
strict: true,
|
|
11
|
+
customTypes: {},
|
|
12
|
+
customValidators: [],
|
|
13
|
+
...options,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
this.#compiledCache = new WeakMap();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ==================================================
|
|
20
|
+
// PUBLIC API
|
|
21
|
+
// ==================================================
|
|
22
|
+
|
|
23
|
+
validate({ schema, data }) {
|
|
24
|
+
const engine = new NativeEngine(this.options);
|
|
25
|
+
return engine.validate({ schema, data });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
compile(schema) {
|
|
29
|
+
if (this.#compiledCache.has(schema)) {
|
|
30
|
+
return this.#compiledCache.get(schema);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const generator = new CodeGenerator(this.options);
|
|
34
|
+
const compiledFn = generator.compile(schema);
|
|
35
|
+
|
|
36
|
+
this.#compiledCache.set(schema, compiledFn);
|
|
37
|
+
|
|
38
|
+
return compiledFn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clearCache() {
|
|
42
|
+
this.#compiledCache = new WeakMap();
|
|
43
|
+
}
|
|
44
|
+
}
|
package/lib/index.js
ADDED
package/lib/package.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hybrid-validator",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Lightweight JSON Schema validator for Node.js and modern JavaScript environments",
|
|
5
|
+
"main": "./lib/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./lib/index.js",
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prepare": "node tools/setGitHooks.js",
|
|
19
|
+
"lint": "prettier -c \"**/*.js\" && eslint .",
|
|
20
|
+
"fix": "prettier --write \"**/*.js\" && eslint . --fix",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"prepublishOnly": "npm run lint && npm test"
|
|
23
|
+
},
|
|
24
|
+
"imports": {
|
|
25
|
+
"#lib": "./lib/index.js",
|
|
26
|
+
"#tools": "./tools/index.js"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"json",
|
|
30
|
+
"json-schema",
|
|
31
|
+
"validator",
|
|
32
|
+
"schema-validation",
|
|
33
|
+
"validation",
|
|
34
|
+
"json-validator"
|
|
35
|
+
],
|
|
36
|
+
"author": "Andrii Kotsiuba",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/AndriiKot/json-schema-validator.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/AndriiKot/json-schema-validator/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/AndriiKot/json-schema-validator#readme",
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"private": false,
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"eslint": "^9.39.2",
|
|
52
|
+
"eslint-config-metarhia": "^9.1.5",
|
|
53
|
+
"prettier": "^3.8.1"
|
|
54
|
+
}
|
|
55
|
+
}
|