toolcraft-schema 0.0.10 → 0.0.12
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/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/validate.d.ts +16 -0
- package/dist/validate.js +323 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { Json } from "./json.js";
|
|
|
2
2
|
import { OneOf } from "./oneof.js";
|
|
3
3
|
import { Record as RecordBuilder } from "./record.js";
|
|
4
4
|
import { Union } from "./union.js";
|
|
5
|
+
import { validate } from "./validate.js";
|
|
5
6
|
import type { JsonValue, JsonValueSchema } from "./json.js";
|
|
6
7
|
import type { OneOfSchema } from "./oneof.js";
|
|
7
8
|
import type { RecordSchema } from "./record.js";
|
|
8
9
|
import type { UnionSchema } from "./union.js";
|
|
10
|
+
import type { ValidationIssue, ValidationResult } from "./validate.js";
|
|
9
11
|
type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "array" | "object";
|
|
10
12
|
type SchemaKind = "string" | "number" | "boolean" | "enum" | "array" | "object" | "optional" | "oneOf" | "union" | "record" | "json";
|
|
11
13
|
type EnumValue = string | number | boolean;
|
|
@@ -134,5 +136,5 @@ export declare const S: {
|
|
|
134
136
|
readonly Json: typeof Json;
|
|
135
137
|
};
|
|
136
138
|
export declare function toJsonSchema(schema: AnySchema): JsonSchema;
|
|
137
|
-
export { Json, OneOf, RecordBuilder as Record, Union };
|
|
138
|
-
export type { JsonValue, JsonValueSchema, OneOfSchema, RecordSchema, UnionSchema };
|
|
139
|
+
export { Json, OneOf, RecordBuilder as Record, Union, validate };
|
|
140
|
+
export type { JsonValue, JsonValueSchema, OneOfSchema, RecordSchema, UnionSchema, ValidationIssue, ValidationResult, };
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Json } from "./json.js";
|
|
|
2
2
|
import { OneOf } from "./oneof.js";
|
|
3
3
|
import { Record as RecordBuilder } from "./record.js";
|
|
4
4
|
import { Union } from "./union.js";
|
|
5
|
+
import { validate } from "./validate.js";
|
|
5
6
|
function withMetadata(schema, jsonSchema) {
|
|
6
7
|
if (schema.description !== undefined) {
|
|
7
8
|
jsonSchema.description = schema.description;
|
|
@@ -213,4 +214,4 @@ export function toJsonSchema(schema) {
|
|
|
213
214
|
return withMetadata(unwrappedSchema, {});
|
|
214
215
|
}
|
|
215
216
|
}
|
|
216
|
-
export { Json, OneOf, RecordBuilder as Record, Union };
|
|
217
|
+
export { Json, OneOf, RecordBuilder as Record, Union, validate };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AnySchema, Static } from "./index.js";
|
|
2
|
+
export type SchemaDescriptor = AnySchema;
|
|
3
|
+
export type ValidationIssue = {
|
|
4
|
+
path: readonly string[];
|
|
5
|
+
expected: string;
|
|
6
|
+
received: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
export type ValidationResult<T> = {
|
|
10
|
+
ok: true;
|
|
11
|
+
value: T;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
issues: readonly ValidationIssue[];
|
|
15
|
+
};
|
|
16
|
+
export declare function validate<S extends SchemaDescriptor>(schema: S, value: unknown): ValidationResult<Static<S>>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
const missingValue = Symbol("missingValue");
|
|
2
|
+
export function validate(schema, value) {
|
|
3
|
+
const state = { issues: [] };
|
|
4
|
+
const result = walkSchema(schema, value, [], state);
|
|
5
|
+
if (state.issues.length > 0) {
|
|
6
|
+
return { ok: false, issues: state.issues };
|
|
7
|
+
}
|
|
8
|
+
return { ok: true, value: (result.present ? result.value : undefined) };
|
|
9
|
+
}
|
|
10
|
+
function walkSchema(schema, value, path, state) {
|
|
11
|
+
if (schema.kind === "optional") {
|
|
12
|
+
return walkOptional(schema, value, path, state);
|
|
13
|
+
}
|
|
14
|
+
if (value === missingValue) {
|
|
15
|
+
addIssue(state, path, expectedFor(schema), "missing", `Expected ${expectedFor(schema)} at ${formatPath(path)}`);
|
|
16
|
+
return { present: false };
|
|
17
|
+
}
|
|
18
|
+
if (value === null && schema.nullable === true) {
|
|
19
|
+
return { present: true, value };
|
|
20
|
+
}
|
|
21
|
+
switch (schema.kind) {
|
|
22
|
+
case "string":
|
|
23
|
+
return walkString(schema, value, path, state);
|
|
24
|
+
case "number":
|
|
25
|
+
return walkNumber(schema, value, path, state);
|
|
26
|
+
case "boolean":
|
|
27
|
+
return walkBoolean(value, path, state);
|
|
28
|
+
case "enum":
|
|
29
|
+
return walkEnum(schema, value, path, state);
|
|
30
|
+
case "array":
|
|
31
|
+
return walkArray(schema, value, path, state);
|
|
32
|
+
case "object":
|
|
33
|
+
return walkObject(schema, value, path, state);
|
|
34
|
+
case "oneOf":
|
|
35
|
+
return walkOneOf(schema, value, path, state);
|
|
36
|
+
case "union":
|
|
37
|
+
return walkUnion(schema, value, path, state);
|
|
38
|
+
case "record":
|
|
39
|
+
return walkRecord(schema, value, path, state);
|
|
40
|
+
case "json":
|
|
41
|
+
return walkJson(value, path, state);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function walkOptional(schema, value, path, state) {
|
|
45
|
+
if (value === missingValue || value === undefined) {
|
|
46
|
+
const defaultValue = getDefault(schema.inner);
|
|
47
|
+
if (defaultValue.present) {
|
|
48
|
+
return { present: true, value: defaultValue.value };
|
|
49
|
+
}
|
|
50
|
+
return { present: false };
|
|
51
|
+
}
|
|
52
|
+
return walkSchema(schema.inner, value, path, state);
|
|
53
|
+
}
|
|
54
|
+
function walkString(schema, value, path, state) {
|
|
55
|
+
if (typeof value !== "string") {
|
|
56
|
+
addExpectedIssue(state, path, "string", value);
|
|
57
|
+
return { present: true, value };
|
|
58
|
+
}
|
|
59
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
60
|
+
const expected = `string with length at least ${schema.minLength}`;
|
|
61
|
+
addIssue(state, path, expected, `string with length ${value.length}`, `Expected ${expected} at ${formatPath(path)}`);
|
|
62
|
+
}
|
|
63
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
64
|
+
const expected = `string with length at most ${schema.maxLength}`;
|
|
65
|
+
addIssue(state, path, expected, `string with length ${value.length}`, `Expected ${expected} at ${formatPath(path)}`);
|
|
66
|
+
}
|
|
67
|
+
if (schema.pattern !== undefined) {
|
|
68
|
+
const pattern = compilePattern(schema.pattern);
|
|
69
|
+
if (pattern === undefined || !pattern.test(value)) {
|
|
70
|
+
const expected = `string matching pattern ${schema.pattern}`;
|
|
71
|
+
addIssue(state, path, expected, value, `Expected ${expected} at ${formatPath(path)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { present: true, value };
|
|
75
|
+
}
|
|
76
|
+
function walkNumber(schema, value, path, state) {
|
|
77
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
78
|
+
addExpectedIssue(state, path, schema.jsonType === "integer" ? "integer" : "number", value);
|
|
79
|
+
return { present: true, value };
|
|
80
|
+
}
|
|
81
|
+
if (schema.jsonType === "integer" && !Number.isInteger(value)) {
|
|
82
|
+
addExpectedIssue(state, path, "integer", value);
|
|
83
|
+
}
|
|
84
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
85
|
+
const expected = `number greater than or equal to ${schema.minimum}`;
|
|
86
|
+
addIssue(state, path, expected, String(value), `Expected ${expected} at ${formatPath(path)}`);
|
|
87
|
+
}
|
|
88
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
89
|
+
const expected = `number less than or equal to ${schema.maximum}`;
|
|
90
|
+
addIssue(state, path, expected, String(value), `Expected ${expected} at ${formatPath(path)}`);
|
|
91
|
+
}
|
|
92
|
+
return { present: true, value };
|
|
93
|
+
}
|
|
94
|
+
function walkBoolean(value, path, state) {
|
|
95
|
+
if (typeof value !== "boolean") {
|
|
96
|
+
addExpectedIssue(state, path, "boolean", value);
|
|
97
|
+
}
|
|
98
|
+
return { present: true, value };
|
|
99
|
+
}
|
|
100
|
+
function walkEnum(schema, value, path, state) {
|
|
101
|
+
if (!schema.values.includes(value)) {
|
|
102
|
+
const expected = `one of ${schema.values.join(", ")}`;
|
|
103
|
+
addIssue(state, path, expected, receivedValue(value), `Expected ${expected} at ${formatPath(path)}`);
|
|
104
|
+
}
|
|
105
|
+
return { present: true, value };
|
|
106
|
+
}
|
|
107
|
+
function walkArray(schema, value, path, state) {
|
|
108
|
+
if (!Array.isArray(value)) {
|
|
109
|
+
addExpectedIssue(state, path, "array", value);
|
|
110
|
+
return { present: true, value };
|
|
111
|
+
}
|
|
112
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
113
|
+
const expected = `array with at least ${schema.minItems} items`;
|
|
114
|
+
addIssue(state, path, expected, `array with ${value.length} items`, `Expected ${expected} at ${formatPath(path)}`);
|
|
115
|
+
}
|
|
116
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
117
|
+
const expected = `array with at most ${schema.maxItems} items`;
|
|
118
|
+
addIssue(state, path, expected, `array with ${value.length} items`, `Expected ${expected} at ${formatPath(path)}`);
|
|
119
|
+
}
|
|
120
|
+
const nextValue = value.map((item, index) => {
|
|
121
|
+
const result = walkSchema(schema.item, item, [...path, String(index)], state);
|
|
122
|
+
return result.present ? result.value : item;
|
|
123
|
+
});
|
|
124
|
+
return { present: true, value: nextValue };
|
|
125
|
+
}
|
|
126
|
+
function walkObject(schema, value, path, state, injectedProperties = {}) {
|
|
127
|
+
if (!isPlainRecord(value)) {
|
|
128
|
+
addExpectedIssue(state, path, "object", value);
|
|
129
|
+
return { present: true, value };
|
|
130
|
+
}
|
|
131
|
+
const nextValue = {};
|
|
132
|
+
const allowedKeys = new Set([...Object.keys(schema.shape), ...Object.keys(injectedProperties)]);
|
|
133
|
+
for (const [key, propertySchema] of Object.entries(schema.shape)) {
|
|
134
|
+
const propertyValue = Object.hasOwn(value, key) ? value[key] : missingValue;
|
|
135
|
+
const result = walkSchema(propertySchema, propertyValue, [...path, key], state);
|
|
136
|
+
if (result.present) {
|
|
137
|
+
nextValue[key] = result.value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [key, injectedValue] of Object.entries(injectedProperties)) {
|
|
141
|
+
if (Object.hasOwn(value, key)) {
|
|
142
|
+
nextValue[key] = value[key];
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
nextValue[key] = injectedValue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const [key, propertyValue] of Object.entries(value)) {
|
|
149
|
+
if (allowedKeys.has(key)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (schema.additionalProperties === true) {
|
|
153
|
+
nextValue[key] = propertyValue;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
addUnexpectedPropertyIssue(state, [...path, key]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { present: true, value: nextValue };
|
|
160
|
+
}
|
|
161
|
+
function walkOneOf(schema, value, path, state) {
|
|
162
|
+
if (!isPlainRecord(value)) {
|
|
163
|
+
addExpectedIssue(state, path, "object", value);
|
|
164
|
+
return { present: true, value };
|
|
165
|
+
}
|
|
166
|
+
const discriminatorValue = value[schema.discriminator];
|
|
167
|
+
if (typeof discriminatorValue !== "string" ||
|
|
168
|
+
!Object.hasOwn(schema.branches, discriminatorValue)) {
|
|
169
|
+
const discriminatorPath = [...path, schema.discriminator];
|
|
170
|
+
const expected = `one of ${Object.keys(schema.branches).join(", ")}`;
|
|
171
|
+
addIssue(state, discriminatorPath, expected, receivedValue(discriminatorValue), `Expected ${expected} at ${formatPath(discriminatorPath)}`);
|
|
172
|
+
return { present: true, value };
|
|
173
|
+
}
|
|
174
|
+
return walkObject(schema.branches[discriminatorValue], value, path, state, {
|
|
175
|
+
[schema.discriminator]: discriminatorValue
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function walkUnion(schema, value, path, state) {
|
|
179
|
+
if (isPlainRecord(value)) {
|
|
180
|
+
const candidateBranches = schema.branches.filter((branch) => hasRequiredKeys(branch, value));
|
|
181
|
+
if (candidateBranches.length === 1) {
|
|
182
|
+
return walkObject(candidateBranches[0], value, path, state);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const matches = [];
|
|
186
|
+
for (const branch of schema.branches) {
|
|
187
|
+
const branchState = { issues: [] };
|
|
188
|
+
const result = walkObject(branch, value, path, branchState);
|
|
189
|
+
if (branchState.issues.length === 0 && result.present) {
|
|
190
|
+
matches.push(result.value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (matches.length === 1) {
|
|
194
|
+
return { present: true, value: matches[0] };
|
|
195
|
+
}
|
|
196
|
+
addIssue(state, path, "exactly one union branch", matches.length === 0 ? "no matching branches" : `${matches.length} matching branches`, `Expected exactly one union branch at ${formatPath(path)}`);
|
|
197
|
+
return { present: true, value };
|
|
198
|
+
}
|
|
199
|
+
function hasRequiredKeys(schema, value) {
|
|
200
|
+
for (const [key, propertySchema] of Object.entries(schema.shape)) {
|
|
201
|
+
if (propertySchema.kind !== "optional" && !Object.hasOwn(value, key)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
function walkRecord(schema, value, path, state) {
|
|
208
|
+
if (!isPlainRecord(value)) {
|
|
209
|
+
addExpectedIssue(state, path, "object", value);
|
|
210
|
+
return { present: true, value };
|
|
211
|
+
}
|
|
212
|
+
const nextValue = {};
|
|
213
|
+
for (const [key, propertyValue] of Object.entries(value)) {
|
|
214
|
+
const result = walkSchema(schema.value, propertyValue, [...path, key], state);
|
|
215
|
+
if (result.present) {
|
|
216
|
+
nextValue[key] = result.value;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { present: true, value: nextValue };
|
|
220
|
+
}
|
|
221
|
+
function walkJson(value, path, state) {
|
|
222
|
+
if (isJsonValue(value)) {
|
|
223
|
+
return { present: true, value };
|
|
224
|
+
}
|
|
225
|
+
addExpectedIssue(state, path, "JSON value", value);
|
|
226
|
+
return { present: true, value };
|
|
227
|
+
}
|
|
228
|
+
function getDefault(schema) {
|
|
229
|
+
if (schema.default !== undefined) {
|
|
230
|
+
return { present: true, value: schema.default };
|
|
231
|
+
}
|
|
232
|
+
if (schema.kind === "optional") {
|
|
233
|
+
return getDefault(schema.inner);
|
|
234
|
+
}
|
|
235
|
+
return { present: false };
|
|
236
|
+
}
|
|
237
|
+
function isPlainRecord(value) {
|
|
238
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const prototype = Object.getPrototypeOf(value);
|
|
242
|
+
return prototype === Object.prototype || prototype === null;
|
|
243
|
+
}
|
|
244
|
+
function isJsonValue(value) {
|
|
245
|
+
if (value === null ||
|
|
246
|
+
typeof value === "string" ||
|
|
247
|
+
typeof value === "number" ||
|
|
248
|
+
typeof value === "boolean") {
|
|
249
|
+
return typeof value !== "number" || Number.isFinite(value);
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
return value.every((item) => isJsonValue(item));
|
|
253
|
+
}
|
|
254
|
+
if (isPlainRecord(value)) {
|
|
255
|
+
return Object.values(value).every((item) => isJsonValue(item));
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
function expectedFor(schema) {
|
|
260
|
+
switch (schema.kind) {
|
|
261
|
+
case "string":
|
|
262
|
+
return "string";
|
|
263
|
+
case "number":
|
|
264
|
+
return schema.jsonType === "integer" ? "integer" : "number";
|
|
265
|
+
case "boolean":
|
|
266
|
+
return "boolean";
|
|
267
|
+
case "enum":
|
|
268
|
+
return `one of ${schema.values.join(", ")}`;
|
|
269
|
+
case "array":
|
|
270
|
+
return "array";
|
|
271
|
+
case "object":
|
|
272
|
+
case "oneOf":
|
|
273
|
+
case "record":
|
|
274
|
+
return "object";
|
|
275
|
+
case "union":
|
|
276
|
+
return "exactly one union branch";
|
|
277
|
+
case "json":
|
|
278
|
+
return "JSON value";
|
|
279
|
+
case "optional":
|
|
280
|
+
return expectedFor(schema.inner);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function addExpectedIssue(state, path, expected, value) {
|
|
284
|
+
addIssue(state, path, expected, receivedType(value), `Expected ${expected} at ${formatPath(path)}`);
|
|
285
|
+
}
|
|
286
|
+
function addUnexpectedPropertyIssue(state, path) {
|
|
287
|
+
addIssue(state, path, "no additional properties", "unknown property", `Unexpected property ${formatPath(path)}`);
|
|
288
|
+
}
|
|
289
|
+
function addIssue(state, path, expected, received, message) {
|
|
290
|
+
state.issues.push({ path, expected, received, message });
|
|
291
|
+
}
|
|
292
|
+
function formatPath(path) {
|
|
293
|
+
return path.length === 0 ? "value" : path.join(".");
|
|
294
|
+
}
|
|
295
|
+
function compilePattern(pattern) {
|
|
296
|
+
try {
|
|
297
|
+
return new RegExp(pattern);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function receivedType(value) {
|
|
304
|
+
if (value === null) {
|
|
305
|
+
return "null";
|
|
306
|
+
}
|
|
307
|
+
if (Array.isArray(value)) {
|
|
308
|
+
return "array";
|
|
309
|
+
}
|
|
310
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
311
|
+
return "integer";
|
|
312
|
+
}
|
|
313
|
+
return typeof value;
|
|
314
|
+
}
|
|
315
|
+
function receivedValue(value) {
|
|
316
|
+
if (typeof value === "string") {
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
if (value === undefined) {
|
|
320
|
+
return "undefined";
|
|
321
|
+
}
|
|
322
|
+
return String(value);
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft-schema",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "rm -rf dist && tsc",
|
|
15
|
-
"test": "cd ../.. && vitest run packages/toolcraft-schema/src
|
|
16
|
-
"test:unit": "cd ../.. && vitest run packages/toolcraft-schema/src
|
|
15
|
+
"test": "cd ../.. && vitest run packages/toolcraft-schema/src/*.test.ts",
|
|
16
|
+
"test:unit": "cd ../.. && vitest run packages/toolcraft-schema/src/*.test.ts",
|
|
17
17
|
"lint": "cd ../.. && eslint packages/toolcraft-schema/src --ext ts && tsc -p packages/toolcraft-schema/tsconfig.json --noEmit"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|