@buckeyestudio/omptype 18.4.13
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/CHANGELOG.md +76 -0
- package/LICENSE +22 -0
- package/README.md +129 -0
- package/package.json +63 -0
- package/src/ark.ts +20 -0
- package/src/compile.ts +1150 -0
- package/src/errors.ts +446 -0
- package/src/from-json-schema.ts +231 -0
- package/src/index.ts +13 -0
- package/src/infer.ts +457 -0
- package/src/interp.ts +766 -0
- package/src/ir.ts +1796 -0
- package/src/json-schema.ts +315 -0
- package/src/keywords.ts +380 -0
- package/src/type.ts +4037 -0
- package/src/typebox.ts +545 -0
- package/src/zod.ts +486 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type { IR, PropIR } from "./ir";
|
|
2
|
+
|
|
3
|
+
export interface JsonSchemaOptions {
|
|
4
|
+
description?: string;
|
|
5
|
+
target?: string;
|
|
6
|
+
dialect?: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Which side of morphs and defaults to describe:
|
|
9
|
+
* - `'input'` — accepted payloads: morphs emit their input shape, defaulted
|
|
10
|
+
* properties are optional (with `default` annotations).
|
|
11
|
+
* - `'output'` — produced values: morphs emit their output shape, defaulted
|
|
12
|
+
* properties are required (always present after validation).
|
|
13
|
+
* Unset keeps the hybrid legacy behavior (morph output, defaults optional).
|
|
14
|
+
*/
|
|
15
|
+
io?: "input" | "output";
|
|
16
|
+
fallback?: (context: { base: Record<string, unknown> }) => unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type JsonSchema = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
interface EmitCtx {
|
|
22
|
+
options?: JsonSchemaOptions;
|
|
23
|
+
/** Emitted alias definitions, attached as `$defs` on the root schema. */
|
|
24
|
+
defs: Map<string, JsonSchema>;
|
|
25
|
+
/** Alias resolver identity → assigned `$defs` name (cycle-safe). */
|
|
26
|
+
refs: Map<() => IR, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Emit the requested JSON Schema dialect represented by an IR tree. */
|
|
30
|
+
export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
|
|
31
|
+
const ctx: EmitCtx = { options, defs: new Map(), refs: new Map() };
|
|
32
|
+
let schema = emit(ir, ctx);
|
|
33
|
+
if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs);
|
|
34
|
+
if (options?.target === "draft-07") schema = toDraft7(schema);
|
|
35
|
+
const dialect = options?.dialect === null ? undefined : (options?.dialect ?? dialectFor(options?.target));
|
|
36
|
+
if (dialect !== undefined) schema.$schema = dialect;
|
|
37
|
+
if (options?.description !== undefined) schema.description = options.description;
|
|
38
|
+
return schema;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dialectFor(target: string | undefined): string | undefined {
|
|
42
|
+
if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
|
|
43
|
+
if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
|
|
44
|
+
return target !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema {
|
|
48
|
+
const replacement = ctx.options?.fallback?.({ base: schema });
|
|
49
|
+
if (replacement === true) return {};
|
|
50
|
+
if (replacement === false) return { not: {} };
|
|
51
|
+
return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toDraft7(schema: JsonSchema): JsonSchema {
|
|
55
|
+
const converted: JsonSchema = {};
|
|
56
|
+
for (const key in schema) {
|
|
57
|
+
const value = schema[key];
|
|
58
|
+
if (key === "$ref" && typeof value === "string") {
|
|
59
|
+
converted.$ref = value.replace("#/$defs/", "#/definitions/");
|
|
60
|
+
} else if (key === "$defs") {
|
|
61
|
+
converted.definitions = toDraft7(value as JsonSchema);
|
|
62
|
+
} else if (key === "prefixItems" && Array.isArray(value)) {
|
|
63
|
+
converted.items = value.map(item =>
|
|
64
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
65
|
+
);
|
|
66
|
+
} else if (key === "items" && "prefixItems" in schema) {
|
|
67
|
+
converted.additionalItems =
|
|
68
|
+
typeof value === "object" && value !== null ? toDraft7(value as JsonSchema) : value;
|
|
69
|
+
} else if (Array.isArray(value)) {
|
|
70
|
+
converted[key] = value.map(item =>
|
|
71
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
72
|
+
);
|
|
73
|
+
} else if (typeof value === "object" && value !== null) {
|
|
74
|
+
converted[key] = toDraft7(value as JsonSchema);
|
|
75
|
+
} else {
|
|
76
|
+
converted[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return converted;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emit(ir: IR, ctx: EmitCtx): JsonSchema {
|
|
83
|
+
let schema: JsonSchema;
|
|
84
|
+
switch (ir.k) {
|
|
85
|
+
case "unknown":
|
|
86
|
+
schema = {};
|
|
87
|
+
break;
|
|
88
|
+
case "undefined":
|
|
89
|
+
schema = fallback({}, ctx);
|
|
90
|
+
break;
|
|
91
|
+
case "null":
|
|
92
|
+
schema = { type: "null" };
|
|
93
|
+
break;
|
|
94
|
+
case "boolean":
|
|
95
|
+
schema = { type: "boolean" };
|
|
96
|
+
break;
|
|
97
|
+
case "bigint":
|
|
98
|
+
schema = { type: "integer" };
|
|
99
|
+
break;
|
|
100
|
+
case "symbol":
|
|
101
|
+
schema = fallback({}, ctx);
|
|
102
|
+
break;
|
|
103
|
+
case "never":
|
|
104
|
+
schema = { not: {} };
|
|
105
|
+
break;
|
|
106
|
+
case "anyobject":
|
|
107
|
+
schema = { type: "object" };
|
|
108
|
+
break;
|
|
109
|
+
case "string":
|
|
110
|
+
schema = emitString(ir);
|
|
111
|
+
break;
|
|
112
|
+
case "number":
|
|
113
|
+
schema = emitNumber(ir);
|
|
114
|
+
break;
|
|
115
|
+
case "lit":
|
|
116
|
+
schema = emitLiteral(ir.v);
|
|
117
|
+
break;
|
|
118
|
+
case "union":
|
|
119
|
+
schema = emitUnion(ir.members, ctx);
|
|
120
|
+
break;
|
|
121
|
+
case "intersection":
|
|
122
|
+
schema = { allOf: ir.members.map(member => emit(member, ctx)) };
|
|
123
|
+
break;
|
|
124
|
+
case "array":
|
|
125
|
+
schema = { type: "array", items: emit(ir.el, ctx) };
|
|
126
|
+
if (ir.min !== undefined) schema.minItems = ir.min;
|
|
127
|
+
if (ir.max !== undefined) schema.maxItems = ir.max;
|
|
128
|
+
break;
|
|
129
|
+
case "tuple": {
|
|
130
|
+
const prefixItems = ir.prefix.map(item => {
|
|
131
|
+
const itemSchema = emit(item.val, ctx);
|
|
132
|
+
if (item.hasDefault) {
|
|
133
|
+
itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def;
|
|
134
|
+
}
|
|
135
|
+
return itemSchema;
|
|
136
|
+
});
|
|
137
|
+
const required = ir.prefix.reduce(
|
|
138
|
+
(count, item) => count + (item.opt || item.hasDefault ? 0 : 1),
|
|
139
|
+
ir.postfix.length,
|
|
140
|
+
);
|
|
141
|
+
schema = { type: "array", prefixItems, minItems: required };
|
|
142
|
+
if (ir.variadic === undefined) {
|
|
143
|
+
schema.items = false;
|
|
144
|
+
} else {
|
|
145
|
+
schema.items = emit(ir.variadic, ctx);
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "object":
|
|
150
|
+
schema = emitObject(ir.props, ir.index, ir.extras, ctx);
|
|
151
|
+
break;
|
|
152
|
+
case "instance":
|
|
153
|
+
schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, ctx);
|
|
154
|
+
break;
|
|
155
|
+
case "refine":
|
|
156
|
+
schema = emit(ir.base, ctx);
|
|
157
|
+
if (ir.json !== undefined) Object.assign(schema, ir.json);
|
|
158
|
+
break;
|
|
159
|
+
case "morph":
|
|
160
|
+
schema = ctx.options?.io === "input" ? emit(ir.input, ctx) : fallback(emit(ir.out ?? ir.input, ctx), ctx);
|
|
161
|
+
break;
|
|
162
|
+
case "alias": {
|
|
163
|
+
const known = ctx.refs.get(ir.resolve);
|
|
164
|
+
if (known !== undefined) {
|
|
165
|
+
schema = { $ref: `#/$defs/${known}` };
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
// Register before lowering so cyclic aliases resolve to the same $ref
|
|
169
|
+
// instead of recursing forever.
|
|
170
|
+
let name = ir.name.replace(/[^\w.-]/g, "_");
|
|
171
|
+
while ([...ctx.refs.values()].includes(name)) name = `${name}_`;
|
|
172
|
+
ctx.refs.set(ir.resolve, name);
|
|
173
|
+
ctx.defs.set(name, emit(ir.resolve(), ctx));
|
|
174
|
+
schema = { $ref: `#/$defs/${name}` };
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "sub":
|
|
178
|
+
if (ctx.options?.io === "output" && ir.schema.opaqueOutput) {
|
|
179
|
+
schema = {};
|
|
180
|
+
} else if (ctx.options?.io === "output" && ir.schema.stepOut !== undefined) {
|
|
181
|
+
schema = emit(ir.schema.stepOut, ctx);
|
|
182
|
+
} else if (ctx.options?.io === "input") {
|
|
183
|
+
schema = emit(ir.schema.ir, ctx);
|
|
184
|
+
} else {
|
|
185
|
+
schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx);
|
|
186
|
+
}
|
|
187
|
+
if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc;
|
|
188
|
+
else if (ir.schema.description !== undefined && ir.descAuto !== true) {
|
|
189
|
+
schema.description = ir.schema.description;
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc;
|
|
194
|
+
return schema;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function emitString(ir: Extract<IR, { k: "string" }>): JsonSchema {
|
|
198
|
+
const schema: JsonSchema = { type: "string" };
|
|
199
|
+
if (ir.min !== undefined) schema.minLength = ir.min;
|
|
200
|
+
if (ir.max !== undefined) schema.maxLength = ir.max;
|
|
201
|
+
if (ir.url) schema.format = "uri";
|
|
202
|
+
return schema;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function emitNumber(ir: Extract<IR, { k: "number" }>): JsonSchema {
|
|
206
|
+
const schema: JsonSchema = { type: ir.int ? "integer" : "number" };
|
|
207
|
+
if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min;
|
|
208
|
+
if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max;
|
|
209
|
+
if (ir.divisor !== undefined) schema.multipleOf = ir.divisor;
|
|
210
|
+
return schema;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function emitLiteral(value: unknown): JsonSchema {
|
|
214
|
+
if (value instanceof Date) return { type: "string", format: "date-time", const: value.toISOString() };
|
|
215
|
+
if (isJsonValue(value)) return { const: value };
|
|
216
|
+
switch (typeof value) {
|
|
217
|
+
case "string":
|
|
218
|
+
return { type: "string" };
|
|
219
|
+
case "number":
|
|
220
|
+
return { type: "number" };
|
|
221
|
+
case "boolean":
|
|
222
|
+
return { type: "boolean" };
|
|
223
|
+
case "bigint":
|
|
224
|
+
return { type: "integer" };
|
|
225
|
+
case "object":
|
|
226
|
+
return { type: "object" };
|
|
227
|
+
default:
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function emitUnion(members: IR[], ctx: EmitCtx): JsonSchema {
|
|
233
|
+
const defined = members.filter(member => member.k !== "undefined");
|
|
234
|
+
if (defined.length === 0) return {};
|
|
235
|
+
if (defined.length === 1) return emit(defined[0], ctx);
|
|
236
|
+
if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
|
|
237
|
+
const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
|
|
238
|
+
const schema: JsonSchema = { enum: values };
|
|
239
|
+
const scalarType = homogeneousScalarType(values);
|
|
240
|
+
if (scalarType !== undefined) schema.type = scalarType;
|
|
241
|
+
return schema;
|
|
242
|
+
}
|
|
243
|
+
return { anyOf: defined.map(member => emit(member, ctx)) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function homogeneousScalarType(values: unknown[]): string | undefined {
|
|
247
|
+
const first = jsonScalarType(values[0]);
|
|
248
|
+
if (first === undefined) return undefined;
|
|
249
|
+
for (let i = 1; i < values.length; i++) {
|
|
250
|
+
if (jsonScalarType(values[i]) !== first) return undefined;
|
|
251
|
+
}
|
|
252
|
+
return first;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function jsonScalarType(value: unknown): string | undefined {
|
|
256
|
+
if (value === null) return "null";
|
|
257
|
+
switch (typeof value) {
|
|
258
|
+
case "string":
|
|
259
|
+
case "number":
|
|
260
|
+
case "boolean":
|
|
261
|
+
return typeof value;
|
|
262
|
+
default:
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emitObject(
|
|
268
|
+
props: PropIR[],
|
|
269
|
+
index: IR | undefined,
|
|
270
|
+
extras: "keep" | "reject" | "delete",
|
|
271
|
+
ctx: EmitCtx,
|
|
272
|
+
): JsonSchema {
|
|
273
|
+
const properties: Record<string, unknown> = {};
|
|
274
|
+
const required: string[] = [];
|
|
275
|
+
const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault);
|
|
276
|
+
// ArkType emits required properties first (each group in declaration
|
|
277
|
+
// order); downstream wire consumers rely on that stable ordering.
|
|
278
|
+
const ordered = [...props.filter(filled), ...props.filter(prop => !filled(prop))];
|
|
279
|
+
for (const prop of ordered) {
|
|
280
|
+
if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string");
|
|
281
|
+
const key = String(prop.key);
|
|
282
|
+
const propertySchema = emit(prop.val, ctx);
|
|
283
|
+
if (prop.hasDefault) {
|
|
284
|
+
propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
|
|
285
|
+
}
|
|
286
|
+
properties[key] = propertySchema;
|
|
287
|
+
if (filled(prop)) required.push(key);
|
|
288
|
+
}
|
|
289
|
+
const schema: JsonSchema = { type: "object", properties };
|
|
290
|
+
if (required.length > 0) schema.required = required;
|
|
291
|
+
if (index !== undefined) schema.additionalProperties = emit(index, ctx);
|
|
292
|
+
else if (extras === "reject") schema.additionalProperties = false;
|
|
293
|
+
return schema;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isJsonValue(value: unknown, seen = new Set<object>()): boolean {
|
|
297
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
298
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
299
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
300
|
+
seen.add(value);
|
|
301
|
+
if (Array.isArray(value)) {
|
|
302
|
+
for (const item of value) {
|
|
303
|
+
if (!isJsonValue(item, seen)) return false;
|
|
304
|
+
}
|
|
305
|
+
seen.delete(value);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const prototype = Object.getPrototypeOf(value);
|
|
309
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
310
|
+
for (const key in value) {
|
|
311
|
+
if (Object.hasOwn(value, key) && !isJsonValue((value as Record<string, unknown>)[key], seen)) return false;
|
|
312
|
+
}
|
|
313
|
+
seen.delete(value);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
package/src/keywords.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { IR, MorphContext } from "./ir";
|
|
2
|
+
|
|
3
|
+
const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\d+?))$/;
|
|
4
|
+
const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/;
|
|
5
|
+
const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/;
|
|
6
|
+
const SEMVER =
|
|
7
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/;
|
|
8
|
+
const UUID = /^[\da-f]{8}-[\da-f]{4}-[1-8][\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
|
|
9
|
+
const IPV4_SEGMENT = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
|
|
10
|
+
const IPV4_ADDRESS = `(?:${IPV4_SEGMENT}[.]){3}${IPV4_SEGMENT}`;
|
|
11
|
+
const IPV4 = new RegExp(`^${IPV4_ADDRESS}$`);
|
|
12
|
+
const IPV6_SEGMENT = "(?:[0-9a-fA-F]{1,4})";
|
|
13
|
+
const IPV6 = new RegExp(
|
|
14
|
+
"^(" +
|
|
15
|
+
`(?:${IPV6_SEGMENT}:){7}(?:${IPV6_SEGMENT}|:)|` +
|
|
16
|
+
`(?:${IPV6_SEGMENT}:){6}(?:${IPV4_ADDRESS}|:${IPV6_SEGMENT}|:)|` +
|
|
17
|
+
`(?:${IPV6_SEGMENT}:){5}(?::${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,2}|:)|` +
|
|
18
|
+
`(?:${IPV6_SEGMENT}:){4}(?:(:${IPV6_SEGMENT}){0,1}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,3}|:)|` +
|
|
19
|
+
`(?:${IPV6_SEGMENT}:){3}(?:(:${IPV6_SEGMENT}){0,2}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,4}|:)|` +
|
|
20
|
+
`(?:${IPV6_SEGMENT}:){2}(?:(:${IPV6_SEGMENT}){0,3}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,5}|:)|` +
|
|
21
|
+
`(?:${IPV6_SEGMENT}:){1}(?:(:${IPV6_SEGMENT}){0,4}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,6}|:)|` +
|
|
22
|
+
`(?::((?::${IPV6_SEGMENT}){0,5}:${IPV4_ADDRESS}|(?::${IPV6_SEGMENT}){1,7}|:))` +
|
|
23
|
+
")(?:%[\\d.A-Za-z]{1,})?$",
|
|
24
|
+
);
|
|
25
|
+
const ISO_DATE =
|
|
26
|
+
/^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-3])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(T((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([,.]\d+(?!:))?)?(\17[0-5]\d([,.]\d+)?)?([Zz]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
|
|
27
|
+
|
|
28
|
+
function pattern(regex: RegExp, expected: string, format?: string): IR {
|
|
29
|
+
return {
|
|
30
|
+
k: "refine",
|
|
31
|
+
base: { k: "string" },
|
|
32
|
+
pred: value => {
|
|
33
|
+
regex.lastIndex = 0;
|
|
34
|
+
return regex.test(value as string);
|
|
35
|
+
},
|
|
36
|
+
expected,
|
|
37
|
+
json: format === undefined ? { pattern: regex.source } : { pattern: regex.source, format },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function morph(input: IR, fn: (value: string, context: MorphContext) => unknown, out?: IR): IR {
|
|
42
|
+
return { k: "morph", input, fn: (value, context) => fn(value as string, context), out };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsableJson(): IR {
|
|
46
|
+
return {
|
|
47
|
+
k: "refine",
|
|
48
|
+
base: { k: "string" },
|
|
49
|
+
pred: value => {
|
|
50
|
+
try {
|
|
51
|
+
JSON.parse(value as string);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
expected: "a JSON string",
|
|
58
|
+
json: { contentMediaType: "application/json" },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsableDate(expected = "a parsable date"): IR {
|
|
63
|
+
return {
|
|
64
|
+
k: "refine",
|
|
65
|
+
base: { k: "string" },
|
|
66
|
+
pred: value => !Number.isNaN(new Date(value as string).valueOf()),
|
|
67
|
+
expected,
|
|
68
|
+
json: { format: "date-time" },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalize(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
|
|
73
|
+
return morph({ k: "string" }, value => value.normalize(form), patternForNormalized(form));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function patternForNormalized(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
|
|
77
|
+
return {
|
|
78
|
+
k: "refine",
|
|
79
|
+
base: { k: "string" },
|
|
80
|
+
pred: value => (value as string).normalize(form) === value,
|
|
81
|
+
expected: `${form}-normalized unicode`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function uuidVersion(version: string): IR {
|
|
86
|
+
return pattern(
|
|
87
|
+
new RegExp(`^[\\da-f]{8}-[\\da-f]{4}-${version}[\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}$`, "i"),
|
|
88
|
+
`a UUIDv${version}`,
|
|
89
|
+
"uuid",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isLuhnValid(input: string): boolean {
|
|
94
|
+
const value = input.replace(/[ -]+/g, "");
|
|
95
|
+
let sum = 0;
|
|
96
|
+
let double = false;
|
|
97
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
98
|
+
let digit = Number(value[index]);
|
|
99
|
+
if (double) {
|
|
100
|
+
digit *= 2;
|
|
101
|
+
if (digit > 9) digit -= 9;
|
|
102
|
+
}
|
|
103
|
+
sum += digit;
|
|
104
|
+
double = !double;
|
|
105
|
+
}
|
|
106
|
+
return value.length > 0 && sum % 10 === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function finiteNumber(expected = "a number"): IR {
|
|
110
|
+
return {
|
|
111
|
+
k: "refine",
|
|
112
|
+
base: { k: "unknown" },
|
|
113
|
+
pred: value => typeof value === "number" && !Number.isNaN(value),
|
|
114
|
+
expected,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function jsonObject(): IR {
|
|
119
|
+
const value: { current?: IR } = {};
|
|
120
|
+
const resolveValue = (): IR => value.current as IR;
|
|
121
|
+
const object: IR = {
|
|
122
|
+
k: "object",
|
|
123
|
+
props: [],
|
|
124
|
+
index: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
|
125
|
+
extras: "keep",
|
|
126
|
+
};
|
|
127
|
+
const array: IR = {
|
|
128
|
+
k: "array",
|
|
129
|
+
el: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
|
130
|
+
desc: "an object",
|
|
131
|
+
};
|
|
132
|
+
value.current = {
|
|
133
|
+
k: "union",
|
|
134
|
+
members: [
|
|
135
|
+
object,
|
|
136
|
+
array,
|
|
137
|
+
{ k: "number" },
|
|
138
|
+
{ k: "string" },
|
|
139
|
+
{ k: "lit", v: false },
|
|
140
|
+
{ k: "null" },
|
|
141
|
+
{ k: "lit", v: true },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
return object;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function instance(ctor: new (...args: never[]) => object, name: string): IR {
|
|
148
|
+
return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keywordFactories: Record<string, () => IR> = {
|
|
152
|
+
string: () => ({ k: "string" }),
|
|
153
|
+
Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }),
|
|
154
|
+
"unknown.any": () => ({ k: "unknown" }),
|
|
155
|
+
Array: () => ({ k: "array", el: { k: "unknown" } }),
|
|
156
|
+
Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"),
|
|
157
|
+
RegExp: () => instance(RegExp, "RegExp"),
|
|
158
|
+
File: () => instance(File, "File"),
|
|
159
|
+
Error: () => instance(Error, "Error"),
|
|
160
|
+
Set: () => instance(Set, "Set"),
|
|
161
|
+
Map: () => instance(Map, "Map"),
|
|
162
|
+
WeakSet: () => instance(WeakSet, "WeakSet"),
|
|
163
|
+
WeakMap: () => instance(WeakMap, "WeakMap"),
|
|
164
|
+
Promise: () => instance(Promise, "Promise"),
|
|
165
|
+
FormData: () => instance(FormData, "FormData"),
|
|
166
|
+
"FormData.parse": () => ({
|
|
167
|
+
k: "morph",
|
|
168
|
+
input: instance(FormData, "FormData"),
|
|
169
|
+
fn: value => {
|
|
170
|
+
const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {};
|
|
171
|
+
for (const [key, entry] of (value as FormData).entries()) {
|
|
172
|
+
const current = out[key];
|
|
173
|
+
out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry];
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
},
|
|
177
|
+
out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" },
|
|
178
|
+
}),
|
|
179
|
+
"object.json": jsonObject,
|
|
180
|
+
"object.json.stringify": () => ({
|
|
181
|
+
k: "morph",
|
|
182
|
+
input: jsonObject(),
|
|
183
|
+
fn: value => JSON.stringify(value),
|
|
184
|
+
out: { k: "string" },
|
|
185
|
+
}),
|
|
186
|
+
"number.epoch": () => ({
|
|
187
|
+
k: "refine",
|
|
188
|
+
base: {
|
|
189
|
+
k: "refine",
|
|
190
|
+
base: {
|
|
191
|
+
k: "refine",
|
|
192
|
+
base: finiteNumber("a number representing a Unix timestamp"),
|
|
193
|
+
pred: value => Number.isInteger(value),
|
|
194
|
+
expected: "an integer representing a Unix timestamp",
|
|
195
|
+
},
|
|
196
|
+
pred: value => (value as number) >= -8_640_000_000_000_000,
|
|
197
|
+
expected: "a Unix timestamp after -8640000000000000",
|
|
198
|
+
},
|
|
199
|
+
pred: value => (value as number) <= 8_640_000_000_000_000,
|
|
200
|
+
expected: "a Unix timestamp before 8640000000000000",
|
|
201
|
+
}),
|
|
202
|
+
"number.safe": () => ({
|
|
203
|
+
k: "refine",
|
|
204
|
+
base: {
|
|
205
|
+
k: "refine",
|
|
206
|
+
base: finiteNumber(),
|
|
207
|
+
pred: value => (value as number) >= Number.MIN_SAFE_INTEGER,
|
|
208
|
+
expected: `at least ${Number.MIN_SAFE_INTEGER}`,
|
|
209
|
+
},
|
|
210
|
+
pred: value => (value as number) <= Number.MAX_SAFE_INTEGER,
|
|
211
|
+
expected: `at most ${Number.MAX_SAFE_INTEGER}`,
|
|
212
|
+
}),
|
|
213
|
+
"number.NaN": () => ({
|
|
214
|
+
k: "refine",
|
|
215
|
+
base: { k: "unknown" },
|
|
216
|
+
pred: Number.isNaN,
|
|
217
|
+
expected: "NaN",
|
|
218
|
+
}),
|
|
219
|
+
"number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }),
|
|
220
|
+
"number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }),
|
|
221
|
+
"string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
|
|
222
|
+
"string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
|
|
223
|
+
"string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),
|
|
224
|
+
"string.base64": () => pattern(/^(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{2}==|[\d+/A-Za-z]{3}=)?$/, "base64-encoded"),
|
|
225
|
+
"string.base64.url": () =>
|
|
226
|
+
pattern(/^(?:[\w-]{4})*(?:[\w-]{2}(?:==|%3D%3D)?|[\w-]{3}(?:=|%3D)?)?$/, "base64url-encoded"),
|
|
227
|
+
"string.capitalize": () => morph({ k: "string" }, value => value.charAt(0).toUpperCase() + value.slice(1)),
|
|
228
|
+
"string.capitalize.preformatted": () => pattern(/^[A-Z].*$/, "capitalized"),
|
|
229
|
+
"string.creditCard": () => ({
|
|
230
|
+
k: "refine",
|
|
231
|
+
base: pattern(
|
|
232
|
+
/^(?:4\d{12}(?:\d{3,6})?|5[1-5]\d{14}|3[47]\d{13}|6(?:011|5\d\d)\d{12,15})$/,
|
|
233
|
+
"a credit card number",
|
|
234
|
+
),
|
|
235
|
+
pred: value => isLuhnValid(value as string),
|
|
236
|
+
expected: "a credit card number",
|
|
237
|
+
}),
|
|
238
|
+
"string.date": () => parsableDate(),
|
|
239
|
+
"string.date.parse": () =>
|
|
240
|
+
morph(parsableDate(), value => new Date(value), { k: "instance", ctor: Date, expected: "a Date" }),
|
|
241
|
+
"string.date.iso": () => pattern(ISO_DATE, "an ISO 8601 date", "date-time"),
|
|
242
|
+
"string.date.iso.parse": () =>
|
|
243
|
+
morph(pattern(ISO_DATE, "an ISO 8601 date", "date-time"), value => new Date(value), {
|
|
244
|
+
k: "instance",
|
|
245
|
+
ctor: Date,
|
|
246
|
+
expected: "a Date",
|
|
247
|
+
}),
|
|
248
|
+
"string.date.epoch": () => pattern(INTEGER, "an integer string representing a Unix timestamp"),
|
|
249
|
+
"string.date.epoch.parse": () =>
|
|
250
|
+
morph(pattern(INTEGER, "an integer string representing a Unix timestamp"), value => new Date(Number(value)), {
|
|
251
|
+
k: "instance",
|
|
252
|
+
ctor: Date,
|
|
253
|
+
expected: "a Date",
|
|
254
|
+
}),
|
|
255
|
+
"string.digits": () => pattern(/^\d*$/, "only digits 0-9"),
|
|
256
|
+
"string.email": () => pattern(EMAIL, "an email address", "email"),
|
|
257
|
+
"string.integer": () => pattern(INTEGER, "a well-formed integer string"),
|
|
258
|
+
"string.integer.parse": () =>
|
|
259
|
+
morph(
|
|
260
|
+
pattern(INTEGER, "a well-formed integer string"),
|
|
261
|
+
(value, context) => {
|
|
262
|
+
const parsed = Number.parseInt(value, 10);
|
|
263
|
+
return Number.isSafeInteger(parsed) ? parsed : context.error("a safe integer string");
|
|
264
|
+
},
|
|
265
|
+
{ k: "number", int: true },
|
|
266
|
+
),
|
|
267
|
+
"string.ip": () => ({
|
|
268
|
+
k: "union",
|
|
269
|
+
members: [pattern(IPV4, "an IPv4 address", "ipv4"), pattern(IPV6, "an IPv6 address", "ipv6")],
|
|
270
|
+
}),
|
|
271
|
+
"string.ip.v4": () => pattern(IPV4, "an IPv4 address", "ipv4"),
|
|
272
|
+
"string.ip.v6": () => pattern(IPV6, "an IPv6 address", "ipv6"),
|
|
273
|
+
"string.json": () => parsableJson(),
|
|
274
|
+
"string.json.parse": () =>
|
|
275
|
+
morph(parsableJson(), (value, context) => {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(value);
|
|
278
|
+
} catch {
|
|
279
|
+
return context.error("a JSON string");
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
"string.lower": () => morph({ k: "string" }, value => value.toLowerCase()),
|
|
283
|
+
"string.lower.preformatted": () => pattern(/^[a-z]*$/, "only lowercase letters"),
|
|
284
|
+
"string.normalize": () => normalize("NFC"),
|
|
285
|
+
"string.normalize.NFC": () => normalize("NFC"),
|
|
286
|
+
"string.normalize.NFC.preformatted": () => patternForNormalized("NFC"),
|
|
287
|
+
"string.normalize.NFD": () => normalize("NFD"),
|
|
288
|
+
"string.normalize.NFD.preformatted": () => patternForNormalized("NFD"),
|
|
289
|
+
"string.normalize.NFKC": () => normalize("NFKC"),
|
|
290
|
+
"string.normalize.NFKC.preformatted": () => patternForNormalized("NFKC"),
|
|
291
|
+
"string.normalize.NFKD": () => normalize("NFKD"),
|
|
292
|
+
"string.normalize.NFKD.preformatted": () => patternForNormalized("NFKD"),
|
|
293
|
+
"string.numeric": () => pattern(NUMERIC, "a well-formed numeric string"),
|
|
294
|
+
"string.numeric.parse": () =>
|
|
295
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
296
|
+
"string.regex": () => ({
|
|
297
|
+
k: "refine",
|
|
298
|
+
base: { k: "string" },
|
|
299
|
+
pred: value => {
|
|
300
|
+
try {
|
|
301
|
+
new RegExp(value as string);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
expected: "a regex pattern",
|
|
308
|
+
json: { format: "regex" },
|
|
309
|
+
}),
|
|
310
|
+
"string.semver": () => pattern(SEMVER, "a semantic version"),
|
|
311
|
+
"string.trim": () => morph({ k: "string" }, value => value.trim()),
|
|
312
|
+
"string.trim.preformatted": () => pattern(/^\S.*\S$|^\S?$/, "trimmed"),
|
|
313
|
+
"string.upper": () => morph({ k: "string" }, value => value.toUpperCase()),
|
|
314
|
+
"string.upper.preformatted": () => pattern(/^[A-Z]*$/, "only uppercase letters"),
|
|
315
|
+
"string.url": () => ({ k: "string", url: true }),
|
|
316
|
+
"string.url.parse": () =>
|
|
317
|
+
morph({ k: "string", url: true }, value => new URL(value), { k: "instance", ctor: URL, expected: "a URL" }),
|
|
318
|
+
"string.uuid": () => pattern(UUID, "a UUID", "uuid"),
|
|
319
|
+
"string.uuid.v1": () => uuidVersion("1"),
|
|
320
|
+
"string.uuid.v2": () => uuidVersion("2"),
|
|
321
|
+
"string.uuid.v3": () => uuidVersion("3"),
|
|
322
|
+
"string.uuid.v4": () => uuidVersion("4"),
|
|
323
|
+
"string.uuid.v5": () => uuidVersion("5"),
|
|
324
|
+
"string.uuid.v6": () => uuidVersion("6"),
|
|
325
|
+
"string.uuid.v7": () => uuidVersion("7"),
|
|
326
|
+
"string.uuid.v8": () => uuidVersion("8"),
|
|
327
|
+
"parse.number": () =>
|
|
328
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
329
|
+
"parse.integer": () =>
|
|
330
|
+
morph(pattern(INTEGER, "a well-formed integer string"), value => Number.parseInt(value, 10), {
|
|
331
|
+
k: "number",
|
|
332
|
+
int: true,
|
|
333
|
+
}),
|
|
334
|
+
"parse.json": () => keywordFactories["string.json.parse"](),
|
|
335
|
+
"parse.date": () => keywordFactories["string.date.parse"](),
|
|
336
|
+
"parse.url": () => keywordFactories["string.url.parse"](),
|
|
337
|
+
"parse.boolean": () =>
|
|
338
|
+
morph(pattern(/^(?:true|false)$/, "a boolean string"), value => value === "true", { k: "boolean" }),
|
|
339
|
+
"parse.bigint": () => morph(pattern(INTEGER, "an integer string"), value => BigInt(value), { k: "bigint" }),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** Lower a built-in keyword into fresh validation IR. */
|
|
343
|
+
export function keywordIR(name: string): IR | undefined {
|
|
344
|
+
return keywordFactories[name]?.();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Lower a regular expression into a string refinement. */
|
|
348
|
+
export function patternIR(regex: RegExp): IR {
|
|
349
|
+
return pattern(regex, `a string matching ${regex}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Lower an ArkType-style template literal into a string pattern. */
|
|
353
|
+
export function templateIR(source: string): IR {
|
|
354
|
+
let patternSource = "^";
|
|
355
|
+
let index = 0;
|
|
356
|
+
const placeholder = /\$\{(string|number|bigint|boolean)\}/g;
|
|
357
|
+
for (let match = placeholder.exec(source); match; match = placeholder.exec(source)) {
|
|
358
|
+
patternSource += escapeRegex(source.slice(index, match.index));
|
|
359
|
+
switch (match[1]) {
|
|
360
|
+
case "number":
|
|
361
|
+
patternSource += "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
|
|
362
|
+
break;
|
|
363
|
+
case "bigint":
|
|
364
|
+
patternSource += "[+-]?\\d+";
|
|
365
|
+
break;
|
|
366
|
+
case "boolean":
|
|
367
|
+
patternSource += "(?:true|false)";
|
|
368
|
+
break;
|
|
369
|
+
default:
|
|
370
|
+
patternSource += ".*";
|
|
371
|
+
}
|
|
372
|
+
index = match.index + match[0].length;
|
|
373
|
+
}
|
|
374
|
+
patternSource += `${escapeRegex(source.slice(index))}$`;
|
|
375
|
+
return pattern(new RegExp(patternSource), `a string matching \`${source}\``);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function escapeRegex(value: string): string {
|
|
379
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
380
|
+
}
|